From 935adebb78aba8ca34960cbc716e45c710331266 Mon Sep 17 00:00:00 2001 From: infiniteflower <139582705+infiniteflower@users.noreply.github.com> Date: Fri, 21 Feb 2025 02:09:43 +0900 Subject: [PATCH] Feat/bridge status controller (#5317) ## Explanation This PR adds a new controller: `BridgeStatusController`. This controller handles the bridge transaction status fetching and polling from the Bridge API. ## References This is a port of the `BridgeStatusController` from Extension: https://github.com/MetaMask/metamask-extension/tree/main/app/scripts/controllers/bridge-status Some minor changes were needed to fill in the missing functions and variables from Extension. This package will be consumed initially by the Metamask Mobile application first. Eventually, we wish to migrate the Extension to use this `core/bridge-status-controller` package. Very closely related to the `BridgeController`: https://github.com/MetaMask/core/pull/5276 ## Changelog ### `@metamask/bridge-status-controller` - ADDED: New `BridgeStatusController`! ### `@metamask/bridge-controller` - CHANGED: `BridgeController` `FeeType` enum now exported as an enum, not just a type ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've highlighted breaking changes using the "BREAKING" category above as appropriate - [ ] I've prepared draft pull requests for clients and consumer packages to resolve any breaking changes --------- Co-authored-by: Elliot Winkler --- .github/CODEOWNERS | 3 + README.md | 22 +- packages/bridge-controller/package.json | 1 - packages/bridge-controller/src/index.ts | 3 +- .../bridge-status-controller/CHANGELOG.md | 14 + packages/bridge-status-controller/LICENSE | 20 + packages/bridge-status-controller/README.md | 15 + .../bridge-status-controller/jest.config.js | 26 + .../bridge-status-controller/package.json | 85 ++ .../bridge-status-controller.test.ts.snap | 227 ++++ .../src/bridge-status-controller.test.ts | 1105 +++++++++++++++++ .../src/bridge-status-controller.ts | 373 ++++++ .../bridge-status-controller/src/constants.ts | 13 + .../bridge-status-controller/src/index.ts | 46 + .../bridge-status-controller/src/types.ts | 332 +++++ .../src/utils/bridge-status.test.ts | 177 +++ .../src/utils/bridge-status.ts | 81 ++ .../src/utils/validators.test.ts | 294 +++++ .../src/utils/validators.ts | 219 ++++ .../tsconfig.build.json | 18 + .../bridge-status-controller/tsconfig.json | 17 + .../bridge-status-controller/typedoc.json | 7 + teams.json | 1 + tsconfig.build.json | 2 + tsconfig.json | 2 + yarn.lock | 34 +- 26 files changed, 3131 insertions(+), 6 deletions(-) create mode 100644 packages/bridge-status-controller/CHANGELOG.md create mode 100644 packages/bridge-status-controller/LICENSE create mode 100644 packages/bridge-status-controller/README.md create mode 100644 packages/bridge-status-controller/jest.config.js create mode 100644 packages/bridge-status-controller/package.json create mode 100644 packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap create mode 100644 packages/bridge-status-controller/src/bridge-status-controller.test.ts create mode 100644 packages/bridge-status-controller/src/bridge-status-controller.ts create mode 100644 packages/bridge-status-controller/src/constants.ts create mode 100644 packages/bridge-status-controller/src/index.ts create mode 100644 packages/bridge-status-controller/src/types.ts create mode 100644 packages/bridge-status-controller/src/utils/bridge-status.test.ts create mode 100644 packages/bridge-status-controller/src/utils/bridge-status.ts create mode 100644 packages/bridge-status-controller/src/utils/validators.test.ts create mode 100644 packages/bridge-status-controller/src/utils/validators.ts create mode 100644 packages/bridge-status-controller/tsconfig.build.json create mode 100644 packages/bridge-status-controller/tsconfig.json create mode 100644 packages/bridge-status-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 886355d6da9..8ecf2033efc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -39,6 +39,7 @@ ## Swaps-Bridge Team /packages/bridge-controller @MetaMask/swaps-engineers +/packages/bridge-status-controller @MetaMask/swaps-engineers ## Portfolio Team /packages/token-search-discovery-controller @MetaMask/portfolio @@ -124,3 +125,5 @@ /packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers /packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/wallet-framework-engineers /packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/wallet-framework-engineers +/packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers +/packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/wallet-framework-engineers diff --git a/README.md b/README.md index d35222d2543..d4c91f573e1 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/assets-controllers`](packages/assets-controllers) - [`@metamask/base-controller`](packages/base-controller) - [`@metamask/bridge-controller`](packages/bridge-controller) +- [`@metamask/bridge-status-controller`](packages/bridge-status-controller) - [`@metamask/build-utils`](packages/build-utils) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/controller-utils`](packages/controller-utils) @@ -74,6 +75,8 @@ linkStyle default opacity:0.5 approval_controller(["@metamask/approval-controller"]); assets_controllers(["@metamask/assets-controllers"]); base_controller(["@metamask/base-controller"]); + bridge_controller(["@metamask/bridge-controller"]); + bridge_status_controller(["@metamask/bridge-status-controller"]); build_utils(["@metamask/build-utils"]); composable_controller(["@metamask/composable-controller"]); controller_utils(["@metamask/controller-utils"]); @@ -107,8 +110,8 @@ linkStyle default opacity:0.5 transaction_controller(["@metamask/transaction-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); accounts_controller --> base_controller; - accounts_controller --> keyring_controller; accounts_controller --> network_controller; + accounts_controller --> keyring_controller; address_book_controller --> base_controller; address_book_controller --> controller_utils; announcement_controller --> base_controller; @@ -123,6 +126,20 @@ linkStyle default opacity:0.5 assets_controllers --> permission_controller; assets_controllers --> preferences_controller; base_controller --> json_rpc_engine; + bridge_controller --> base_controller; + bridge_controller --> controller_utils; + bridge_controller --> polling_controller; + bridge_controller --> transaction_controller; + bridge_controller --> accounts_controller; + bridge_controller --> eth_json_rpc_provider; + bridge_controller --> network_controller; + bridge_status_controller --> base_controller; + bridge_status_controller --> controller_utils; + bridge_status_controller --> polling_controller; + bridge_status_controller --> accounts_controller; + bridge_status_controller --> bridge_controller; + bridge_status_controller --> network_controller; + bridge_status_controller --> transaction_controller; composable_controller --> base_controller; composable_controller --> json_rpc_engine; earn_controller --> base_controller; @@ -139,7 +156,6 @@ linkStyle default opacity:0.5 gas_fee_controller --> network_controller; json_rpc_middleware_stream --> json_rpc_engine; keyring_controller --> base_controller; - keyring_controller --> message_manager; logging_controller --> base_controller; logging_controller --> controller_utils; message_manager --> base_controller; @@ -150,6 +166,7 @@ linkStyle default opacity:0.5 multichain --> permission_controller; multichain_network_controller --> base_controller; multichain_network_controller --> keyring_controller; + multichain_network_controller --> network_controller; multichain_transactions_controller --> base_controller; multichain_transactions_controller --> polling_controller; multichain_transactions_controller --> accounts_controller; @@ -203,6 +220,7 @@ linkStyle default opacity:0.5 token_search_discovery_controller --> base_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; + transaction_controller --> remote_feature_flag_controller; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; transaction_controller --> eth_json_rpc_provider; diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 0706efea2b4..d6895095a0c 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -58,7 +58,6 @@ "@metamask/accounts-controller": "^24.0.0", "@metamask/auto-changelog": "^3.4.4", "@metamask/eth-json-rpc-provider": "^4.1.8", - "@metamask/json-rpc-engine": "^10.0.3", "@metamask/network-controller": "^22.2.1", "@metamask/transaction-controller": "^46.0.0", "@types/jest": "^27.4.1", diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 415682821fe..9f321d51420 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -19,7 +19,6 @@ export type { Quote, QuoteResponse, ChainId, - FeeType, FeeData, TxData, BridgeFeatureFlagsKey, @@ -34,6 +33,8 @@ export type { BridgeControllerMessenger, } from './types'; +export { FeeType } from './types'; + export { ALLOWED_BRIDGE_CHAIN_IDS, BridgeClientId, diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md new file mode 100644 index 00000000000..8fcf72c699c --- /dev/null +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# 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 + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/bridge-status-controller/LICENSE b/packages/bridge-status-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/bridge-status-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2025 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/bridge-status-controller/README.md b/packages/bridge-status-controller/README.md new file mode 100644 index 00000000000..3c364ca0571 --- /dev/null +++ b/packages/bridge-status-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/bridge-status-controller` + +Manages bridge-related status fetching functionality for MetaMask. + +## Installation + +`yarn add @metamask/bridge-status-controller` + +or + +`npm install @metamask/bridge-status-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/bridge-status-controller/jest.config.js b/packages/bridge-status-controller/jest.config.js new file mode 100644 index 00000000000..15a04af42e5 --- /dev/null +++ b/packages/bridge-status-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 94, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json new file mode 100644 index 00000000000..38c4073ccde --- /dev/null +++ b/packages/bridge-status-controller/package.json @@ -0,0 +1,85 @@ +{ + "name": "@metamask/bridge-status-controller", + "version": "0.0.0", + "description": "Manages bridge-related status fetching functionality for MetaMask", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/bridge-status-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/bridge-status-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/bridge-status-controller", + "publish:preview": "yarn npm publish --tag preview", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^8.0.0", + "@metamask/bridge-controller": "^0.0.0", + "@metamask/controller-utils": "^11.5.0", + "@metamask/polling-controller": "^12.0.3", + "@metamask/utils": "^11.2.0" + }, + "devDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/network-controller": "^22.2.1", + "@metamask/transaction-controller": "^46.0.0", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "jest-environment-jsdom": "^27.5.1", + "lodash": "^4.17.21", + "nock": "^13.3.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/accounts-controller": "^24.0.0", + "@metamask/bridge-controller": "^0.0.0", + "@metamask/network-controller": "^22.0.0", + "@metamask/transaction-controller": "^46.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap new file mode 100644 index 00000000000..fdf64b3dbd4 --- /dev/null +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -0,0 +1,227 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`BridgeStatusController constructor rehydrates the tx history state 1`] = ` +Object { + "bridgeTxMetaId1": Object { + "account": "0xaccount1", + "estimatedProcessingTimeInSeconds": 15, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "pricingData": Object { + "amountSent": "1.234", + "amountSentInUsd": undefined, + "quotedGasInUsd": undefined, + "quotedReturnInUsd": undefined, + }, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": Object { + "srcChain": Object { + "chainId": 42161, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + "txMetaId": "bridgeTxMetaId1", + }, +} +`; + +exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx history state 1`] = ` +Object { + "bridgeTxMetaId1": Object { + "account": "0xaccount1", + "estimatedProcessingTimeInSeconds": 15, + "hasApprovalTx": false, + "initialDestAssetBalance": undefined, + "pricingData": Object { + "amountSent": "1.234", + "amountSentInUsd": undefined, + "quotedGasInUsd": undefined, + "quotedReturnInUsd": undefined, + }, + "quote": Object { + "bridgeId": "lifi", + "bridges": Array [ + "across", + ], + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "destTokenAmount": "990654755978612", + "feeData": Object { + "metabridge": Object { + "amount": "8750000000000", + "asset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + }, + }, + "requestId": "197c402f-cb96-4096-9f8c-54aed84ca776", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + "srcTokenAmount": "991250000000000", + "steps": Array [ + Object { + "action": "bridge", + "destAmount": "990654755978612", + "destAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 10, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.63", + "symbol": "ETH", + }, + "destChainId": 10, + "protocol": Object { + "displayName": "Across", + "icon": "https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png", + "name": "across", + }, + "srcAmount": "991250000000000", + "srcAsset": Object { + "address": "0x0000000000000000000000000000000000000000", + "chainId": 42161, + "coinKey": "ETH", + "decimals": 18, + "icon": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "logoURI": "https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png", + "name": "ETH", + "priceUSD": "2478.7", + "symbol": "ETH", + }, + "srcChainId": 42161, + }, + ], + }, + "slippagePercentage": 0, + "startTime": 1729964825189, + "status": Object { + "srcChain": Object { + "chainId": 42161, + "txHash": "0xsrcTxHash1", + }, + "status": "PENDING", + }, + "targetContractAddress": "0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC", + "txMetaId": "bridgeTxMetaId1", + }, +} +`; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts new file mode 100644 index 00000000000..b79647acafe --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -0,0 +1,1105 @@ +/* eslint-disable jest/no-restricted-matchers */ +/* eslint-disable jest/no-conditional-in-test */ +import { BridgeClientId } from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { numberToHex } from '@metamask/utils'; + +import { BridgeStatusController } from './bridge-status-controller'; +import { DEFAULT_BRIDGE_STATUS_STATE } from './constants'; +import type { BridgeStatusControllerMessenger } from './types'; +import type { + BridgeId, + StatusTypes, + ActionTypes, + StartPollingForBridgeTxStatusArgsSerialized, + BridgeHistoryItem, +} from './types'; +import * as bridgeStatusUtils from './utils/bridge-status'; +import { flushPromises } from '../../../tests/helpers'; + +const EMPTY_INIT_STATE = { + bridgeStatusState: { ...DEFAULT_BRIDGE_STATUS_STATE }, +}; + +const MockStatusResponse = { + getPending: ({ + srcTxHash = '0xsrcTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'PENDING' as StatusTypes, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + token: {}, + }, + }), + getComplete: ({ + srcTxHash = '0xsrcTxHash1', + destTxHash = '0xdestTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'COMPLETE' as StatusTypes, + isExpectedToken: true, + bridge: 'across' as BridgeId, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + txHash: destTxHash, + amount: '990654755978611', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }), + getFailed: ({ + srcTxHash = '0xsrcTxHash1', + srcChainId = 42161, + destChainId = 10, + } = {}) => ({ + status: 'FAILED' as StatusTypes, + srcChain: { + chainId: srcChainId, + txHash: srcTxHash, + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: destChainId, + token: {}, + }, + }), +}; + +const getMockQuote = ({ srcChainId = 42161, destChainId = 10 } = {}) => ({ + requestId: '197c402f-cb96-4096-9f8c-54aed84ca776', + srcChainId, + srcTokenAmount: '991250000000000', + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destChainId, + destTokenAmount: '990654755978612', + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + feeData: { + metabridge: { + amount: '8750000000000', + asset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + bridgeId: 'lifi', + bridges: ['across'], + steps: [ + { + action: 'bridge' as ActionTypes, + srcChainId, + destChainId, + protocol: { + name: 'across', + displayName: 'Across', + icon: 'https://raw.githubusercontent.com/lifinance/types/main/src/assets/icons/bridges/acrossv2.png', + }, + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: srcChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.7', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + chainId: destChainId, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2478.63', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + srcAmount: '991250000000000', + destAmount: '990654755978612', + }, + ], +}); + +const getMockStartPollingForBridgeTxStatusArgs = ({ + txMetaId = 'bridgeTxMetaId1', + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, +} = {}): StartPollingForBridgeTxStatusArgsSerialized => ({ + bridgeTxMeta: { + id: txMetaId, + } as TransactionMeta, + statusRequest: { + bridgeId: 'lifi', + srcTxHash, + bridge: 'across', + srcChainId, + destChainId, + quote: getMockQuote({ srcChainId, destChainId }), + refuel: false, + }, + quoteResponse: { + quote: getMockQuote({ srcChainId, destChainId }), + trade: { + chainId: srcChainId, + to: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + from: account, + value: '0x038d7ea4c68000', + data: '0x3ce33bff0000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038d7ea4c6800000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000d6c6966694164617074657256320000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001c0000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000e397c4883ec89ed4fc9d258f00c689708b2799c9000000000000000000000000000000000000000000000000000000000000000a0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000038589602234000000000000000000000000000000000000000000000000000000000000000140000000000000000000000000000000000000000000000000000007f544a44c0000000000000000000000000056ca675c3633cc16bd6849e2b431d4e8de5e23bf000000000000000000000000000000000000000000000000000000000000006c5a39b10a4f4f0747826140d2c5fe6ef47965741f6f7a4734bf784bf3ae3f24520000000a000222266cc2dca0671d2a17ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffd00dfeeddeadbeef8932eb23bad9bddb5cf81426f78279a53c6c3b7100000000000000000000000000000000000000009ce3c510b3f58edc8d53ae708056e30926f62d0b42d5c9b61c391bb4e8a2c1917f8ed995169ffad0d79af2590303e83c57e15a9e0b248679849556c2e03a1c811b', + gasLimit: 282915, + }, + approval: null, + estimatedProcessingTimeInSeconds: 15, + sentAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + toTokenAmount: { amount: '1.234', valueInCurrency: null, usd: null }, + totalNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + totalMaxNetworkFee: { amount: '1.234', valueInCurrency: null, usd: null }, + gasFee: { amount: '1.234', valueInCurrency: null, usd: null }, + adjustedReturn: { valueInCurrency: null, usd: null }, + swapRate: '1.234', + cost: { valueInCurrency: null, usd: null }, + }, + startTime: 1729964825189, + slippagePercentage: 0, + initialDestAssetBalance: undefined, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', +}); + +const MockTxHistory = { + getInitNoSrcTxHash: ({ + txMetaId = 'bridgeTxMetaId1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { amountSent: '1.234' }, + status: MockStatusResponse.getPending({ + srcChainId, + }), + hasApprovalTx: false, + }, + }), + getInit: ({ + txMetaId = 'bridgeTxMetaId1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { amountSent: '1.234' }, + status: MockStatusResponse.getPending({ + srcChainId, + }), + hasApprovalTx: false, + }, + }), + getPending: ({ + txMetaId = 'bridgeTxMetaId1', + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getPending({ + srcTxHash, + srcChainId, + }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: undefined, + }, + hasApprovalTx: false, + completionTime: undefined, + }, + }), + getComplete: ({ + txMetaId = 'bridgeTxMetaId1', + srcTxHash = '0xsrcTxHash1', + account = '0xaccount1', + srcChainId = 42161, + destChainId = 10, + } = {}): Record => ({ + [txMetaId]: { + txMetaId, + quote: getMockQuote({ srcChainId, destChainId }), + startTime: 1729964825189, + completionTime: 1736277625746, + estimatedProcessingTimeInSeconds: 15, + slippagePercentage: 0, + account, + status: MockStatusResponse.getComplete({ srcTxHash }), + targetContractAddress: '0x23981fC34e69eeDFE2BD9a0a9fCb0719Fe09DbFC', + initialDestAssetBalance: undefined, + pricingData: { + amountSent: '1.234', + amountSentInUsd: undefined, + quotedGasInUsd: undefined, + quotedReturnInUsd: undefined, + }, + hasApprovalTx: false, + }, + }), +}; + +const getMessengerMock = ({ + account = '0xaccount1', + srcChainId = 42161, + txHash = '0xsrcTxHash1', + txMetaId = 'bridgeTxMetaId1', +} = {}) => + ({ + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: account }; + } else if (method === 'NetworkController:findNetworkClientIdByChainId') { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: numberToHex(srcChainId), + }, + }; + } else if (method === 'TransactionController:getState') { + return { + transactions: [ + { + id: txMetaId, + hash: txHash, + }, + ], + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + }) as unknown as jest.Mocked; + +const executePollingWithPendingStatus = async () => { + // Setup + jest.useFakeTimers(); + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const startPollingSpy = jest.spyOn(bridgeStatusController, 'startPolling'); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return MockStatusResponse.getPending(); + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + return { + bridgeStatusController, + startPollingSpy, + fetchBridgeTxStatusSpy, + }; +}; + +describe('BridgeStatusController', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.clearAllTimers(); + }); + + describe('constructor', () => { + it('should setup correctly', () => { + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + expect(bridgeStatusController.state).toStrictEqual(EMPTY_INIT_STATE); + }); + it('rehydrates the tx history state', async () => { + // Setup + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + state: { + bridgeStatusState: { + txHistory: MockTxHistory.getPending(), + }, + }, + }); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Assertion + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toMatchSnapshot(); + }); + it('restarts polling for history items that are not complete', async () => { + // Setup + jest.useFakeTimers(); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Execution + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + state: { + bridgeStatusState: { + txHistory: MockTxHistory.getPending(), + }, + }, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + }); + }); + describe('startPollingForBridgeTxStatus', () => { + it('sets the inital tx history state', async () => { + // Setup + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + + // Assertion + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toMatchSnapshot(); + }); + it('starts polling and updates the tx history when the status response is received', async () => { + const { + bridgeStatusController, + startPollingSpy, + fetchBridgeTxStatusSpy, + } = await executePollingWithPendingStatus(); + + // Assertions + expect(startPollingSpy).toHaveBeenCalledTimes(1); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalled(); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toStrictEqual(MockTxHistory.getPending()); + }); + it('stops polling when the status response is complete', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + const bridgeStatusController = new BridgeStatusController({ + messenger: getMessengerMock(), + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + const stopPollingByNetworkClientIdSpy = jest.spyOn( + bridgeStatusController, + 'stopPollingByPollingToken', + ); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + fetchBridgeTxStatusSpy.mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(stopPollingByNetworkClientIdSpy).toHaveBeenCalledTimes(1); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toStrictEqual(MockTxHistory.getComplete()); + + // Cleanup + jest.restoreAllMocks(); + }); + it('does not poll if the srcTxHash is not available', async () => { + // Setup + jest.useFakeTimers(); + + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: numberToHex(42161), + }, + }; + } else if (method === 'TransactionController:getState') { + return { + transactions: [ + { + id: 'bridgeTxMetaId1', + hash: undefined, + }, + ], + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const fetchBridgeTxStatusSpy = jest.spyOn( + bridgeStatusUtils, + 'fetchBridgeTxStatus', + ); + + // Start polling with args that have no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs(); + startPollingArgs.statusRequest.srcTxHash = undefined; + bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); + + // Advance timer to trigger polling + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).not.toHaveBeenCalled(); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toHaveProperty('bridgeTxMetaId1'); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .status.srcChain.txHash, + ).toBeUndefined(); + + // Cleanup + jest.restoreAllMocks(); + }); + it('emits bridgeTransactionComplete event when the status response is complete', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + + const messengerMock = getMessengerMock(); + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(messengerMock.publish).toHaveBeenCalledWith( + 'BridgeStatusController:bridgeTransactionComplete', + { + bridgeHistoryItem: expect.objectContaining({ + txMetaId: 'bridgeTxMetaId1', + status: expect.objectContaining({ + status: 'COMPLETE', + }), + }), + }, + ); + + // Cleanup + jest.restoreAllMocks(); + }); + it('emits bridgeTransactionFailed event when the status response is failed', async () => { + // Setup + jest.useFakeTimers(); + jest.spyOn(Date, 'now').mockImplementation(() => { + return MockTxHistory.getComplete().bridgeTxMetaId1.completionTime ?? 10; + }); + + const messengerMock = getMessengerMock(); + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getFailed(); + }); + + // Execution + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Assertions + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + expect(messengerMock.publish).toHaveBeenCalledWith( + 'BridgeStatusController:bridgeTransactionFailed', + { + bridgeHistoryItem: expect.objectContaining({ + txMetaId: 'bridgeTxMetaId1', + status: expect.objectContaining({ + status: 'FAILED', + }), + }), + }, + ); + + // Cleanup + jest.restoreAllMocks(); + }); + it('updates the srcTxHash when one is available', async () => { + // Setup + jest.useFakeTimers(); + let getStateCallCount = 0; + + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: numberToHex(42161), + }, + }; + } else if (method === 'TransactionController:getState') { + getStateCallCount += 1; + return { + transactions: [ + { + id: 'bridgeTxMetaId1', + hash: getStateCallCount === 0 ? undefined : '0xnewTxHash', + }, + ], + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + + // Start polling with no srcTxHash + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs(); + startPollingArgs.statusRequest.srcTxHash = undefined; + bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); + + // Verify initial state has no srcTxHash + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .status.srcChain.txHash, + ).toBeUndefined(); + + // Advance timer to trigger polling with new hash + jest.advanceTimersByTime(10000); + await flushPromises(); + + // Verify the srcTxHash was updated + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .status.srcChain.txHash, + ).toBe('0xnewTxHash'); + + // Cleanup + jest.restoreAllMocks(); + }); + }); + describe('resetState', () => { + it('resets the state', async () => { + const { bridgeStatusController } = + await executePollingWithPendingStatus(); + + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toStrictEqual(MockTxHistory.getPending()); + bridgeStatusController.resetState(); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toStrictEqual(EMPTY_INIT_STATE.bridgeStatusState.txHistory); + }); + }); + describe('wipeBridgeStatus', () => { + it('wipes the bridge status for the given address', async () => { + // Setup + jest.useFakeTimers(); + + let getSelectedAccountCalledTimes = 0; + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + let account; + + if (getSelectedAccountCalledTimes === 0) { + account = '0xaccount1'; + } else { + account = '0xaccount2'; + } + getSelectedAccountCalledTimes += 1; + return { address: account }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: numberToHex(42161), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + destTxHash: '0xdestTxHash2', + }); + }); + + // Start polling for 0xaccount1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs(), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for 0xaccount2 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + txMetaId: 'bridgeTxMetaId2', + srcTxHash: '0xsrcTxHash2', + account: '0xaccount2', + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check that both accounts have a tx history entry + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toHaveProperty('bridgeTxMetaId1'); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory, + ).toHaveProperty('bridgeTxMetaId2'); + + // Wipe the status for 1 account only + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].account).toBe('0xaccount2'); + }); + it('wipes the bridge status for all networks if ignoreNetwork is true', () => { + // Setup + jest.useFakeTimers(); + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + chainId: numberToHex(42161), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }); + }); + + // Start polling for chainId 42161 to chainId 1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .quote.srcChainId, + ).toBe(42161); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .quote.destChainId, + ).toBe(1); + + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId2 + .quote.srcChainId, + ).toBe(10); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId2 + .quote.destChainId, + ).toBe(123); + + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: true, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(0); + }); + it('wipes the bridge status only for the current network if ignoreNetwork is false', () => { + // Setup + jest.useFakeTimers(); + const messengerMock = { + call: jest.fn((method: string) => { + if (method === 'AccountsController:getSelectedAccount') { + return { address: '0xaccount1' }; + } else if ( + method === 'NetworkController:findNetworkClientIdByChainId' + ) { + return 'networkClientId'; + } else if (method === 'NetworkController:getState') { + return { selectedNetworkClientId: 'networkClientId' }; + } else if (method === 'NetworkController:getNetworkClientById') { + return { + configuration: { + // This is what controls the selectedNetwork and what gets wiped in this test + chainId: numberToHex(42161), + }, + }; + } + return null; + }), + publish: jest.fn(), + registerActionHandler: jest.fn(), + registerInitialEventPayload: jest.fn(), + } as unknown as jest.Mocked; + const bridgeStatusController = new BridgeStatusController({ + messenger: messengerMock, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + }); + const fetchBridgeTxStatusSpy = jest + .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete(); + }) + .mockImplementationOnce(async () => { + return MockStatusResponse.getComplete({ + srcTxHash: '0xsrcTxHash2', + }); + }); + + // Start polling for chainId 42161 to chainId 1 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash1', + txMetaId: 'bridgeTxMetaId1', + srcChainId: 42161, + destChainId: 1, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); + + // Start polling for chainId 10 to chainId 123 + bridgeStatusController.startPollingForBridgeTxStatus( + getMockStartPollingForBridgeTxStatusArgs({ + account: '0xaccount1', + srcTxHash: '0xsrcTxHash2', + txMetaId: 'bridgeTxMetaId2', + srcChainId: 10, + destChainId: 123, + }), + ); + jest.advanceTimersByTime(10_000); + expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(2); + + // Check we have a tx history entry for each chainId + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .quote.srcChainId, + ).toBe(42161); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId1 + .quote.destChainId, + ).toBe(1); + + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId2 + .quote.srcChainId, + ).toBe(10); + expect( + bridgeStatusController.state.bridgeStatusState.txHistory.bridgeTxMetaId2 + .quote.destChainId, + ).toBe(123); + + bridgeStatusController.wipeBridgeStatus({ + address: '0xaccount1', + ignoreNetwork: false, + }); + + // Assertions + const txHistoryItems = Object.values( + bridgeStatusController.state.bridgeStatusState.txHistory, + ); + expect(txHistoryItems).toHaveLength(1); + expect(txHistoryItems[0].quote.srcChainId).toBe(10); + expect(txHistoryItems[0].quote.destChainId).toBe(123); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts new file mode 100644 index 00000000000..aa12119af3d --- /dev/null +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -0,0 +1,373 @@ +import type { StateMetadata } from '@metamask/base-controller'; +import type { BridgeClientId } from '@metamask/bridge-controller'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import { numberToHex, type Hex } from '@metamask/utils'; + +import { + BRIDGE_STATUS_CONTROLLER_NAME, + DEFAULT_BRIDGE_STATUS_STATE, + REFRESH_INTERVAL_MS, +} from './constants'; +import { StatusTypes, type BridgeStatusControllerMessenger } from './types'; +import type { + BridgeStatusControllerState, + StartPollingForBridgeTxStatusArgsSerialized, + BridgeStatusState, + FetchFunction, +} from './types'; +import { + fetchBridgeTxStatus, + getStatusRequestWithSrcTxHash, +} from './utils/bridge-status'; + +const metadata: StateMetadata = { + // We want to persist the bridge status state so that we can show the proper data for the Activity list + // basically match the behavior of TransactionController + bridgeStatusState: { + persist: true, + anonymous: false, + }, +}; + +/** The input to start polling for the {@link BridgeStatusController} */ +type BridgeStatusPollingInput = FetchBridgeTxStatusArgs; + +type SrcTxMetaId = string; +export type FetchBridgeTxStatusArgs = { + bridgeTxMetaId: string; +}; +export class BridgeStatusController extends StaticIntervalPollingController()< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState, + BridgeStatusControllerMessenger +> { + #pollingTokensByTxMetaId: Record = {}; + + readonly #clientId: BridgeClientId; + + readonly #fetchFn: FetchFunction; + + constructor({ + messenger, + state, + clientId, + fetchFn, + }: { + messenger: BridgeStatusControllerMessenger; + state?: { bridgeStatusState?: Partial }; + clientId: BridgeClientId; + fetchFn: FetchFunction; + }) { + super({ + name: BRIDGE_STATUS_CONTROLLER_NAME, + metadata, + messenger, + // Restore the persisted state + state: { + ...state, + bridgeStatusState: { + ...DEFAULT_BRIDGE_STATUS_STATE, + ...state?.bridgeStatusState, + }, + }, + }); + + this.#clientId = clientId; + this.#fetchFn = fetchFn; + + // Register action handlers + this.messagingSystem.registerActionHandler( + `${BRIDGE_STATUS_CONTROLLER_NAME}:startPollingForBridgeTxStatus`, + this.startPollingForBridgeTxStatus.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_STATUS_CONTROLLER_NAME}:wipeBridgeStatus`, + this.wipeBridgeStatus.bind(this), + ); + this.messagingSystem.registerActionHandler( + `${BRIDGE_STATUS_CONTROLLER_NAME}:resetState`, + this.resetState.bind(this), + ); + + // Set interval + this.setIntervalLength(REFRESH_INTERVAL_MS); + + // If you close the extension, but keep the browser open, the polling continues + // If you close the browser, the polling stops + // Check for historyItems that do not have a status of complete and restart polling + this.#restartPollingForIncompleteHistoryItems(); + } + + resetState = () => { + this.update((state) => { + state.bridgeStatusState = { + ...DEFAULT_BRIDGE_STATUS_STATE, + }; + }); + }; + + wipeBridgeStatus = ({ + address, + ignoreNetwork, + }: { + address: string; + ignoreNetwork: boolean; + }) => { + // Wipe all networks for this address + if (ignoreNetwork) { + this.update((state) => { + state.bridgeStatusState = { + ...DEFAULT_BRIDGE_STATUS_STATE, + }; + }); + } else { + const { selectedNetworkClientId } = this.messagingSystem.call( + 'NetworkController:getState', + ); + const selectedNetworkClient = this.messagingSystem.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + const selectedChainId = selectedNetworkClient.configuration.chainId; + + this.#wipeBridgeStatusByChainId(address, selectedChainId); + } + }; + + readonly #restartPollingForIncompleteHistoryItems = () => { + // Check for historyItems that do not have a status of complete and restart polling + const { bridgeStatusState } = this.state; + const historyItems = Object.values(bridgeStatusState.txHistory); + const incompleteHistoryItems = historyItems + .filter( + (historyItem) => + historyItem.status.status === StatusTypes.PENDING || + historyItem.status.status === StatusTypes.UNKNOWN, + ) + .filter((historyItem) => { + // Check if we are already polling this tx, if so, skip restarting polling for that + const srcTxMetaId = historyItem.txMetaId; + const pollingToken = this.#pollingTokensByTxMetaId[srcTxMetaId]; + return !pollingToken; + }); + + incompleteHistoryItems.forEach((historyItem) => { + const bridgeTxMetaId = historyItem.txMetaId; + + // We manually call startPolling() here rather than go through startPollingForBridgeTxStatus() + // because we don't want to overwrite the existing historyItem in state + this.#pollingTokensByTxMetaId[bridgeTxMetaId] = this.startPolling({ + bridgeTxMetaId, + }); + }); + }; + + startPollingForBridgeTxStatus = ( + startPollingForBridgeTxStatusArgs: StartPollingForBridgeTxStatusArgsSerialized, + ) => { + const { + bridgeTxMeta, + statusRequest, + quoteResponse, + startTime, + slippagePercentage, + initialDestAssetBalance, + targetContractAddress, + } = startPollingForBridgeTxStatusArgs; + const { address: account } = this.#getSelectedAccount(); + + // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API + // We know it's in progress but not the exact status yet + const txHistoryItem = { + txMetaId: bridgeTxMeta.id, + quote: quoteResponse.quote, + startTime, + estimatedProcessingTimeInSeconds: + quoteResponse.estimatedProcessingTimeInSeconds, + slippagePercentage, + pricingData: { + amountSent: quoteResponse.sentAmount.amount, + amountSentInUsd: quoteResponse.sentAmount.usd ?? undefined, + quotedGasInUsd: quoteResponse.gasFee.usd ?? undefined, + quotedReturnInUsd: quoteResponse.toTokenAmount.usd ?? undefined, + }, + initialDestAssetBalance, + targetContractAddress, + account, + status: { + // We always have a PENDING status when we start polling for a tx, don't need the Bridge API for that + // Also we know the bare minimum fields for status at this point in time + status: StatusTypes.PENDING, + srcChain: { + chainId: statusRequest.srcChainId, + txHash: statusRequest.srcTxHash, + }, + }, + hasApprovalTx: Boolean(quoteResponse.approval), + }; + this.update((state) => { + // Use the txMeta.id as the key so we can reference the txMeta in TransactionController + state.bridgeStatusState.txHistory[bridgeTxMeta.id] = txHistoryItem; + }); + + this.#pollingTokensByTxMetaId[bridgeTxMeta.id] = this.startPolling({ + bridgeTxMetaId: bridgeTxMeta.id, + }); + }; + + // This will be called after you call this.startPolling() + // The args passed in are the args you passed in to startPolling() + _executePoll = async (pollingInput: BridgeStatusPollingInput) => { + await this.#fetchBridgeTxStatus(pollingInput); + }; + + #getSelectedAccount() { + return this.messagingSystem.call('AccountsController:getSelectedAccount'); + } + + readonly #fetchBridgeTxStatus = async ({ + bridgeTxMetaId, + }: FetchBridgeTxStatusArgs) => { + const { bridgeStatusState } = this.state; + + try { + // We try here because we receive 500 errors from Bridge API if we try to fetch immediately after submitting the source tx + // Oddly mostly happens on Optimism, never on Arbitrum. By the 2nd fetch, the Bridge API responds properly. + // Also srcTxHash may not be available immediately for STX, so we don't want to fetch in those cases + const historyItem = bridgeStatusState.txHistory[bridgeTxMetaId]; + const srcTxHash = this.#getSrcTxHash(bridgeTxMetaId); + if (!srcTxHash) { + return; + } + + this.#updateSrcTxHash(bridgeTxMetaId, srcTxHash); + + const statusRequest = getStatusRequestWithSrcTxHash( + historyItem.quote, + srcTxHash, + ); + const status = await fetchBridgeTxStatus( + statusRequest, + this.#clientId, + this.#fetchFn, + ); + const newBridgeHistoryItem = { + ...historyItem, + status, + completionTime: + status.status === StatusTypes.COMPLETE || + status.status === StatusTypes.FAILED + ? Date.now() + : undefined, // TODO make this more accurate by looking up dest txHash block time + }; + + // No need to purge these on network change or account change, TransactionController does not purge either. + // TODO In theory we can skip checking status if it's not the current account/network + // we need to keep track of the account that this is associated with as well so that we don't show it in Activity list for other accounts + // First stab at this will not stop polling when you are on a different account + this.update((state) => { + state.bridgeStatusState.txHistory[bridgeTxMetaId] = + newBridgeHistoryItem; + }); + + const pollingToken = this.#pollingTokensByTxMetaId[bridgeTxMetaId]; + + if ( + (status.status === StatusTypes.COMPLETE || + status.status === StatusTypes.FAILED) && + pollingToken + ) { + this.stopPollingByPollingToken(pollingToken); + + if (status.status === StatusTypes.COMPLETE) { + this.messagingSystem.publish( + `${BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionComplete`, + { bridgeHistoryItem: newBridgeHistoryItem }, + ); + } + if (status.status === StatusTypes.FAILED) { + this.messagingSystem.publish( + `${BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionFailed`, + { bridgeHistoryItem: newBridgeHistoryItem }, + ); + } + } + } catch (e) { + console.log('Failed to fetch bridge tx status', e); + } + }; + + readonly #getSrcTxHash = (bridgeTxMetaId: string): string | undefined => { + const { bridgeStatusState } = this.state; + // Prefer the srcTxHash from bridgeStatusState so we don't have to l ook up in TransactionController + // But it is possible to have bridgeHistoryItem in state without the srcTxHash yet when it is an STX + const srcTxHash = + bridgeStatusState.txHistory[bridgeTxMetaId].status.srcChain.txHash; + + if (srcTxHash) { + return srcTxHash; + } + + // Look up in TransactionController if txMeta has been updated with the srcTxHash + const txControllerState = this.messagingSystem.call( + 'TransactionController:getState', + ); + const txMeta = txControllerState.transactions.find( + (tx) => tx.id === bridgeTxMetaId, + ); + return txMeta?.hash; + }; + + readonly #updateSrcTxHash = (bridgeTxMetaId: string, srcTxHash: string) => { + const { bridgeStatusState } = this.state; + if (bridgeStatusState.txHistory[bridgeTxMetaId].status.srcChain.txHash) { + return; + } + + this.update((state) => { + state.bridgeStatusState.txHistory[bridgeTxMetaId].status.srcChain.txHash = + srcTxHash; + }); + }; + + // Wipes the bridge status for the given address and chainId + // Will match only source chainId to the selectedChainId + readonly #wipeBridgeStatusByChainId = ( + address: string, + selectedChainId: Hex, + ) => { + const sourceTxMetaIdsToDelete = Object.keys( + this.state.bridgeStatusState.txHistory, + ).filter((txMetaId) => { + const bridgeHistoryItem = + this.state.bridgeStatusState.txHistory[txMetaId]; + + const hexSourceChainId = numberToHex(bridgeHistoryItem.quote.srcChainId); + + return ( + bridgeHistoryItem.account === address && + hexSourceChainId === selectedChainId + ); + }); + + sourceTxMetaIdsToDelete.forEach((sourceTxMetaId) => { + const pollingToken = this.#pollingTokensByTxMetaId[sourceTxMetaId]; + + if (pollingToken) { + this.stopPollingByPollingToken( + this.#pollingTokensByTxMetaId[sourceTxMetaId], + ); + } + }); + + this.update((state) => { + state.bridgeStatusState.txHistory = sourceTxMetaIdsToDelete.reduce( + (acc, sourceTxMetaId) => { + delete acc[sourceTxMetaId]; + return acc; + }, + state.bridgeStatusState.txHistory, + ); + }); + }; +} diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts new file mode 100644 index 00000000000..bc523df71db --- /dev/null +++ b/packages/bridge-status-controller/src/constants.ts @@ -0,0 +1,13 @@ +import type { BridgeStatusState } from './types'; + +export const REFRESH_INTERVAL_MS = 10 * 1000; + +export const BRIDGE_STATUS_CONTROLLER_NAME = 'BridgeStatusController'; + +export const DEFAULT_BRIDGE_STATUS_STATE: BridgeStatusState = { + txHistory: {}, +}; + +export const DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE = { + bridgeStatusState: { ...DEFAULT_BRIDGE_STATUS_STATE }, +}; diff --git a/packages/bridge-status-controller/src/index.ts b/packages/bridge-status-controller/src/index.ts new file mode 100644 index 00000000000..d013d0cfeeb --- /dev/null +++ b/packages/bridge-status-controller/src/index.ts @@ -0,0 +1,46 @@ +// Export constants +export { + REFRESH_INTERVAL_MS, + DEFAULT_BRIDGE_STATUS_STATE, + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE, +} from './constants'; + +export type { + FetchFunction, + StatusRequest, + StatusRequestDto, + StatusRequestWithSrcTxHash, + Asset, + SrcChainStatus, + DestChainStatus, + StatusResponse, + RefuelStatusResponse, + RefuelData, + BridgeHistoryItem, + BridgeStatusState, + BridgeStatusControllerState, + BridgeStatusControllerMessenger, + BridgeStatusControllerActions, + BridgeStatusControllerGetStateAction, + BridgeStatusControllerStartPollingForBridgeTxStatusAction, + BridgeStatusControllerWipeBridgeStatusAction, + BridgeStatusControllerResetStateAction, + BridgeStatusControllerEvents, + BridgeStatusControllerStateChangeEvent, + BridgeStatusControllerBridgeTransactionCompleteEvent, + BridgeStatusControllerBridgeTransactionFailedEvent, + StartPollingForBridgeTxStatusArgs, + StartPollingForBridgeTxStatusArgsSerialized, + TokenAmountValuesSerialized, + QuoteMetadataSerialized, +} from './types'; + +export { + StatusTypes, + BridgeId, + FeeType, + ActionTypes, + BridgeStatusAction, +} from './types'; + +export { BridgeStatusController } from './bridge-status-controller'; diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts new file mode 100644 index 00000000000..26a142ad1e6 --- /dev/null +++ b/packages/bridge-status-controller/src/types.ts @@ -0,0 +1,332 @@ +import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedMessenger, +} from '@metamask/base-controller'; +import type { + ChainId, + Quote, + QuoteMetadata, + QuoteResponse, +} from '@metamask/bridge-controller'; +import type { + NetworkControllerFindNetworkClientIdByChainIdAction, + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, +} from '@metamask/network-controller'; +import type { TransactionControllerGetStateAction } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { BridgeStatusController } from './bridge-status-controller'; +import type { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; + +// All fields need to be types not interfaces, same with their children fields +// o/w you get a type error + +export type FetchFunction = ( + input: RequestInfo | URL, + init?: RequestInit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any +) => Promise; + +export enum StatusTypes { + UNKNOWN = 'UNKNOWN', + FAILED = 'FAILED', + PENDING = 'PENDING', + COMPLETE = 'COMPLETE', +} + +export type StatusRequest = { + bridgeId: string; // lifi, socket, squid + srcTxHash?: string; // lifi, socket, squid, might be undefined for STX + bridge: string; // lifi, socket, squid + srcChainId: ChainId; // lifi, socket, squid + destChainId: ChainId; // lifi, socket, squid + quote?: Quote; // squid + refuel?: boolean; // lifi +}; + +export type StatusRequestDto = Omit< + StatusRequest, + 'quote' | 'srcChainId' | 'destChainId' | 'refuel' +> & { + srcChainId: string; // lifi, socket, squid + destChainId: string; // lifi, socket, squid + requestId?: string; + refuel?: string; // lifi +}; + +export type StatusRequestWithSrcTxHash = StatusRequest & { + srcTxHash: string; +}; + +export type Asset = { + chainId: ChainId; + address: string; + symbol: string; + name: string; + decimals: number; + icon?: string | null; +}; + +export type SrcChainStatus = { + chainId: ChainId; + /** + * The txHash of the transaction on the source chain. + * This might be undefined for smart transactions (STX) + */ + txHash?: string; + /** + * The atomic amount of the token sent minus fees on the source chain + */ + amount?: string; + token?: Record | Asset; +}; + +export type DestChainStatus = { + chainId: ChainId; + txHash?: string; + /** + * The atomic amount of the token received on the destination chain + */ + amount?: string; + token?: Record | Asset; +}; + +export enum BridgeId { + HOP = 'hop', + CELER = 'celer', + CELERCIRCLE = 'celercircle', + CONNEXT = 'connext', + POLYGON = 'polygon', + AVALANCHE = 'avalanche', + MULTICHAIN = 'multichain', + AXELAR = 'axelar', + ACROSS = 'across', + STARGATE = 'stargate', +} + +export enum FeeType { + METABRIDGE = 'metabridge', + REFUEL = 'refuel', +} + +export type FeeData = { + amount: string; + asset: Asset; +}; + +export type Protocol = { + displayName?: string; + icon?: string; + name?: string; // for legacy quotes +}; + +export enum ActionTypes { + BRIDGE = 'bridge', + SWAP = 'swap', + REFUEL = 'refuel', +} + +export type Step = { + action: ActionTypes; + srcChainId: ChainId; + destChainId?: ChainId; + srcAsset: Asset; + destAsset: Asset; + srcAmount: string; + destAmount: string; + protocol: Protocol; +}; + +export type StatusResponse = { + status: StatusTypes; + srcChain: SrcChainStatus; + destChain?: DestChainStatus; + bridge?: BridgeId; + isExpectedToken?: boolean; + isUnrecognizedRouterAddress?: boolean; + refuel?: RefuelStatusResponse; +}; + +export type RefuelStatusResponse = object & StatusResponse; + +export type RefuelData = object & Step; + +export type BridgeHistoryItem = { + txMetaId: string; // Need this to handle STX that might not have a txHash immediately + quote: Quote; + status: StatusResponse; + startTime?: number; // timestamp in ms + estimatedProcessingTimeInSeconds: number; + slippagePercentage: number; + completionTime?: number; // timestamp in ms + pricingData?: { + /** + * From QuoteMetadata.sentAmount.amount, the actual amount sent by user in non-atomic decimal form + */ + amountSent: string; + amountSentInUsd?: string; + quotedGasInUsd?: string; // from QuoteMetadata.gasFee.usd + quotedReturnInUsd?: string; // from QuoteMetadata.toTokenAmount.usd + quotedRefuelSrcAmountInUsd?: string; + quotedRefuelDestAmountInUsd?: string; + }; + initialDestAssetBalance?: string; + targetContractAddress?: string; + account: string; + hasApprovalTx: boolean; +}; + +export enum BridgeStatusAction { + START_POLLING_FOR_BRIDGE_TX_STATUS = 'startPollingForBridgeTxStatus', + WIPE_BRIDGE_STATUS = 'wipeBridgeStatus', + GET_STATE = 'getState', + RESET_STATE = 'resetState', +} + +export type TokenAmountValuesSerialized = { + amount: string; + valueInCurrency: string | null; + usd: string | null; +}; + +export type QuoteMetadataSerialized = { + gasFee: TokenAmountValuesSerialized; + /** + * The total network fee for the bridge transaction + * estimatedGasFees + relayerFees + */ + totalNetworkFee: TokenAmountValuesSerialized; + /** + * The total max network fee for the bridge transaction + * maxGasFees + relayerFees + */ + totalMaxNetworkFee: TokenAmountValuesSerialized; + toTokenAmount: TokenAmountValuesSerialized; + /** + * The adjusted return for the bridge transaction + * destTokenAmount - totalNetworkFee + */ + adjustedReturn: Omit; + /** + * The actual amount sent by user in non-atomic decimal form + * srcTokenAmount + metabridgeFee + */ + sentAmount: TokenAmountValuesSerialized; + swapRate: string; // destTokenAmount / sentAmount + /** + * The cost of the bridge transaction + * sentAmount - adjustedReturn + */ + cost: Omit; +}; + +export type StartPollingForBridgeTxStatusArgs = { + bridgeTxMeta: TransactionMeta; + statusRequest: StatusRequest; + quoteResponse: QuoteResponse & QuoteMetadata; + startTime?: BridgeHistoryItem['startTime']; + slippagePercentage: BridgeHistoryItem['slippagePercentage']; + initialDestAssetBalance?: BridgeHistoryItem['initialDestAssetBalance']; + targetContractAddress?: BridgeHistoryItem['targetContractAddress']; +}; + +/** + * Chrome: The BigNumber values are automatically serialized to strings when sent to the background + * Firefox: The BigNumber values are not serialized to strings when sent to the background, + * so we force the ui to do it manually, by using StartPollingForBridgeTxStatusArgsSerialized type on the startPollingForBridgeTxStatus action + */ +export type StartPollingForBridgeTxStatusArgsSerialized = Omit< + StartPollingForBridgeTxStatusArgs, + 'quoteResponse' +> & { + quoteResponse: QuoteResponse & QuoteMetadataSerialized; +}; + +export type SourceChainTxMetaId = string; + +export type BridgeStatusState = { + txHistory: Record; +}; + +export type BridgeStatusControllerState = { + bridgeStatusState: BridgeStatusState; +}; + +// Actions +type BridgeStatusControllerAction< + FunctionName extends keyof BridgeStatusController, +> = { + type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:${FunctionName}`; + handler: BridgeStatusController[FunctionName]; +}; + +export type BridgeStatusControllerGetStateAction = ControllerGetStateAction< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState +>; + +// Maps to BridgeController function names +export type BridgeStatusControllerStartPollingForBridgeTxStatusAction = + BridgeStatusControllerAction; + +export type BridgeStatusControllerWipeBridgeStatusAction = + BridgeStatusControllerAction; + +export type BridgeStatusControllerResetStateAction = + BridgeStatusControllerAction; + +export type BridgeStatusControllerActions = + | BridgeStatusControllerStartPollingForBridgeTxStatusAction + | BridgeStatusControllerWipeBridgeStatusAction + | BridgeStatusControllerResetStateAction + | BridgeStatusControllerGetStateAction; + +// Events +export type BridgeStatusControllerStateChangeEvent = ControllerStateChangeEvent< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerState +>; + +export type BridgeStatusControllerBridgeTransactionCompleteEvent = { + type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionComplete`; + payload: [{ bridgeHistoryItem: BridgeHistoryItem }]; +}; + +export type BridgeStatusControllerBridgeTransactionFailedEvent = { + type: `${typeof BRIDGE_STATUS_CONTROLLER_NAME}:bridgeTransactionFailed`; + payload: [{ bridgeHistoryItem: BridgeHistoryItem }]; +}; + +export type BridgeStatusControllerEvents = + | BridgeStatusControllerStateChangeEvent + | BridgeStatusControllerBridgeTransactionCompleteEvent + | BridgeStatusControllerBridgeTransactionFailedEvent; + +/** + * The external actions available to the BridgeStatusController. + */ +type AllowedActions = + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetStateAction + | NetworkControllerGetNetworkClientByIdAction + | AccountsControllerGetSelectedAccountAction + | TransactionControllerGetStateAction; + +/** + * The external events available to the BridgeStatusController. + */ +type AllowedEvents = never; + +/** + * The messenger for the BridgeStatusController. + */ +export type BridgeStatusControllerMessenger = RestrictedMessenger< + typeof BRIDGE_STATUS_CONTROLLER_NAME, + BridgeStatusControllerActions | AllowedActions, + BridgeStatusControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; diff --git a/packages/bridge-status-controller/src/utils/bridge-status.test.ts b/packages/bridge-status-controller/src/utils/bridge-status.test.ts new file mode 100644 index 00000000000..bbf334a9f6b --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge-status.test.ts @@ -0,0 +1,177 @@ +import { BridgeClientId, FeeType } from '@metamask/bridge-controller'; + +import { + fetchBridgeTxStatus, + BRIDGE_STATUS_BASE_URL, + getStatusRequestDto, +} from './bridge-status'; +import type { StatusRequestWithSrcTxHash, FetchFunction } from '../types'; + +describe('utils', () => { + const mockStatusRequest: StatusRequestWithSrcTxHash = { + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: 1, + destChainId: 137, + refuel: false, + quote: { + requestId: 'req-123', + bridgeId: 'socket', + bridges: ['socket'], + srcChainId: 1, + destChainId: 137, + srcAsset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + icon: undefined, + }, + srcTokenAmount: '', + destAsset: { + chainId: 137, + address: '0x456', + symbol: 'USDC', + name: 'USD Coin', + decimals: 6, + icon: undefined, + }, + destTokenAmount: '', + feeData: { + [FeeType.METABRIDGE]: { + amount: '100', + asset: { + chainId: 1, + address: '0x123', + symbol: 'ETH', + name: 'Ether', + decimals: 18, + icon: 'eth.jpeg', + }, + }, + }, + steps: [], + }, + }; + + const mockValidResponse = { + status: 'PENDING', + srcChain: { + chainId: 1, + txHash: '0x123', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 1, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2518.47', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: 137, + token: {}, + }, + }; + + describe('fetchBridgeTxStatus', () => { + const mockClientId = BridgeClientId.EXTENSION; + + it('should successfully fetch and validate bridge transaction status', async () => { + const mockFetch: FetchFunction = jest + .fn() + .mockResolvedValue(mockValidResponse); + + const result = await fetchBridgeTxStatus( + mockStatusRequest, + mockClientId, + mockFetch, + ); + + // Verify the fetch was called with correct parameters + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(BRIDGE_STATUS_BASE_URL), + { + headers: { 'X-Client-Id': mockClientId }, + }, + ); + + // Verify URL contains all required parameters + const callUrl = (mockFetch as jest.Mock).mock.calls[0][0]; + expect(callUrl).toContain(`bridgeId=${mockStatusRequest.bridgeId}`); + expect(callUrl).toContain(`srcTxHash=${mockStatusRequest.srcTxHash}`); + expect(callUrl).toContain( + `requestId=${mockStatusRequest.quote?.requestId}`, + ); + + // Verify response + expect(result).toStrictEqual(mockValidResponse); + }); + + it('should throw error when response validation fails', async () => { + const invalidResponse = { + invalid: 'response', + }; + + const mockFetch: FetchFunction = jest + .fn() + .mockResolvedValue(invalidResponse); + + await expect( + fetchBridgeTxStatus(mockStatusRequest, mockClientId, mockFetch), + ).rejects.toThrow('Invalid response from bridge'); + }); + + it('should handle fetch errors', async () => { + const mockFetch: FetchFunction = jest + .fn() + .mockRejectedValue(new Error('Network error')); + + await expect( + fetchBridgeTxStatus(mockStatusRequest, mockClientId, mockFetch), + ).rejects.toThrow('Network error'); + }); + }); + + describe('getStatusRequestDto', () => { + it('should handle status request with quote', () => { + const result = getStatusRequestDto(mockStatusRequest); + + expect(result).toStrictEqual({ + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: '1', + destChainId: '137', + refuel: 'false', + requestId: 'req-123', + }); + }); + + it('should handle status request without quote', () => { + const statusRequestWithoutQuote = { + ...mockStatusRequest, + quote: undefined, + }; + + const result = getStatusRequestDto(statusRequestWithoutQuote); + + expect(result).toStrictEqual({ + bridgeId: 'socket', + srcTxHash: '0x123', + bridge: 'socket', + srcChainId: '1', + destChainId: '137', + refuel: 'false', + }); + expect(result).not.toHaveProperty('requestId'); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/bridge-status.ts b/packages/bridge-status-controller/src/utils/bridge-status.ts new file mode 100644 index 00000000000..8a8fa50936e --- /dev/null +++ b/packages/bridge-status-controller/src/utils/bridge-status.ts @@ -0,0 +1,81 @@ +import type { Quote } from '@metamask/bridge-controller'; +import { getBridgeApiBaseUrl } from '@metamask/bridge-controller'; + +import { validateResponse, validators } from './validators'; +import type { + StatusResponse, + StatusRequestWithSrcTxHash, + StatusRequestDto, + FetchFunction, +} from '../types'; + +export const getClientIdHeader = (clientId: string) => ({ + 'X-Client-Id': clientId, +}); + +export const BRIDGE_STATUS_BASE_URL = `${getBridgeApiBaseUrl()}/getTxStatus`; + +export const getStatusRequestDto = ( + statusRequest: StatusRequestWithSrcTxHash, +): StatusRequestDto => { + const { quote, ...statusRequestNoQuote } = statusRequest; + + const statusRequestNoQuoteFormatted = Object.fromEntries( + Object.entries(statusRequestNoQuote).map(([key, value]) => [ + key, + value.toString(), + ]), + ) as unknown as Omit; + + const requestId: { requestId: string } | Record = + quote?.requestId ? { requestId: quote.requestId } : {}; + + return { + ...statusRequestNoQuoteFormatted, + ...requestId, + }; +}; + +export const fetchBridgeTxStatus = async ( + statusRequest: StatusRequestWithSrcTxHash, + clientId: string, + fetchFn: FetchFunction, +) => { + const statusRequestDto = getStatusRequestDto(statusRequest); + const params = new URLSearchParams(statusRequestDto); + + // Fetch + const url = `${BRIDGE_STATUS_BASE_URL}?${params.toString()}`; + + const rawTxStatus = await fetchFn(url, { + headers: getClientIdHeader(clientId), + }); + + // Validate + const isValid = validateResponse( + validators, + rawTxStatus, + BRIDGE_STATUS_BASE_URL, + ); + if (!isValid) { + throw new Error('Invalid response from bridge'); + } + + // Return + return rawTxStatus; +}; + +export const getStatusRequestWithSrcTxHash = ( + quote: Quote, + srcTxHash: string, +): StatusRequestWithSrcTxHash => { + return { + bridgeId: quote.bridgeId, + srcTxHash, + bridge: quote.bridges[0], + srcChainId: quote.srcChainId, + destChainId: quote.destChainId, + quote, + refuel: Boolean(quote.refuel), + }; +}; diff --git a/packages/bridge-status-controller/src/utils/validators.test.ts b/packages/bridge-status-controller/src/utils/validators.test.ts new file mode 100644 index 00000000000..90128f6583e --- /dev/null +++ b/packages/bridge-status-controller/src/utils/validators.test.ts @@ -0,0 +1,294 @@ +import { validateResponse, validators } from './validators'; +import type { StatusResponse } from '../types'; + +const BridgeTxStatusResponses = { + STATUS_PENDING_VALID: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '10', + token: {}, + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS: { + status: 'PENDING', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + }, + }, + STATUS_PENDING_VALID_MISSING_FIELDS_2: { + status: 'PENDING', + bridge: 'hop', + srcChain: { + chainId: 42161, + txHash: + '0x5cbda572c686a5a57fe62735325e408f9164f77a4787df29ce13edef765adaa9', + amount: '991250000000000', + token: { + chainId: 42161, + address: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee', + symbol: 'ETH', + name: 'Ethereum', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/ETH', + logoURI: 'https://media.socket.tech/tokens/all/ETH', + chainAgnosticId: null, + }, + }, + }, + STATUS_PENDING_INVALID_MISSING_FIELDS: { + status: 'PENDING', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x76a65e4cea35d8732f0e3250faed00ba764ad5a0e7c51cb1bafbc9d76ac0b325', + amount: '991250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2550.12', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + token: {}, + }, + }, + STATUS_COMPLETE_VALID: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '42161', + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_VALID_MISSING_FIELDS: { + status: 'COMPLETE', + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x9fdc426692aba1f81e145834602ed59ed331054e5b91a09a673cb12d4b4f6a33', + amount: '4956250000000000', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 10, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2649.21', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + destChain: { + chainId: '42161', + txHash: + '0x3a494e672717f9b1f2b64a48a19985842d82d0747400fccebebc7a4e99c8eaab', + amount: '4926701727965948', + token: { + address: '0x0000000000000000000000000000000000000000', + chainId: 42161, + symbol: 'ETH', + decimals: 18, + name: 'ETH', + coinKey: 'ETH', + logoURI: + 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + priceUSD: '2648.72', + icon: 'https://raw.githubusercontent.com/trustwallet/assets/master/blockchains/ethereum/assets/0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2/logo.png', + }, + }, + }, + STATUS_COMPLETE_VALID_MISSING_FIELDS_2: { + status: 'COMPLETE', + isExpectedToken: false, + bridge: 'across', + srcChain: { + chainId: 10, + txHash: + '0x4c57876fad21fb5149af5a58a4aba2ca9d6b212014505dd733b75667ca4f0f2b', + amount: '991250000000000', + token: { + chainId: 10, + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: 'https://media.socket.tech/tokens/all/WETH', + // logoURI: 'https://media.socket.tech/tokens/all/WETH', + // chainAgnosticId: 'ETH', + }, + }, + destChain: { + chainId: 8453, + txHash: + '0x60c4cad7c3eb14c7b3ace40cd4015b90927dadacbdc8673f404bea6a5603844b', + amount: '988339336750062', + token: { + chainId: 8453, + address: '0x4200000000000000000000000000000000000006', + symbol: 'WETH', + name: 'Wrapped Ether', + decimals: 18, + icon: null, + // logoURI: null, + // chainAgnosticId: null, + }, + }, + }, + STATUS_COMPLETE_INVALID_MISSING_FIELDS: { + status: 'COMPLETE', + isExpectedToken: true, + bridge: 'across', + }, + STATUS_FAILED_VALID: { + status: 'FAILED', + bridge: 'across', + srcChain: { + chainId: 42161, + txHash: + '0x4c57876fad21fb5149af5a58a4aba2ca9d6b212014505dd733b75667ca4f0f2b', + token: {}, + }, + }, +}; + +describe('validators', () => { + describe('bridgeStatusValidator', () => { + it.each([ + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID, + expected: true, + description: 'valid pending bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS, + expected: true, + description: 'valid pending bridge status missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_VALID_MISSING_FIELDS_2, + expected: true, + description: 'valid pending bridge status missing fields 2', + }, + { + input: BridgeTxStatusResponses.STATUS_PENDING_INVALID_MISSING_FIELDS, + expected: false, + description: 'pending bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID, + expected: true, + description: 'valid complete bridge status', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_INVALID_MISSING_FIELDS, + expected: false, + description: 'complete bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID_MISSING_FIELDS_2, + expected: true, + description: 'complete bridge status with missing fields 2', + }, + { + input: BridgeTxStatusResponses.STATUS_COMPLETE_VALID_MISSING_FIELDS, + expected: true, + description: 'complete bridge status with missing fields', + }, + { + input: BridgeTxStatusResponses.STATUS_FAILED_VALID, + expected: true, + description: 'valid failed bridge status', + }, + { + input: undefined, + expected: false, + description: 'undefined', + }, + { + input: null, + expected: false, + description: 'null', + }, + { + input: {}, + expected: false, + description: 'empty object', + }, + ])( + 'should return $expected for $description', + ({ input, expected }: { input: unknown; expected: boolean }) => { + const res = validateResponse( + validators, + input, + 'dummyurl.com', + ); + expect(res).toBe(expected); + }, + ); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/validators.ts b/packages/bridge-status-controller/src/utils/validators.ts new file mode 100644 index 00000000000..cc32e0f031f --- /dev/null +++ b/packages/bridge-status-controller/src/utils/validators.ts @@ -0,0 +1,219 @@ +import { isValidHexAddress } from '@metamask/controller-utils'; + +import { BRIDGE_STATUS_BASE_URL } from './bridge-status'; +import type { DestChainStatus, SrcChainStatus, Asset } from '../types'; +import { BridgeId, StatusTypes } from '../types'; + +type Validator = { + property: keyof ExpectedResponse | string; + type: string; + validator: (value: DataToValidate) => boolean; +}; + +export const validHex = (value: unknown) => + typeof value === 'string' && Boolean(value.match(/^0x[a-f0-9]+$/u)); +const isValidObject = (v: unknown): v is object => + typeof v === 'object' && v !== null; + +export const validateData = ( + validators: Validator[], + object: unknown, + urlUsed: string, + logError = true, +): object is ExpectedResponse => { + return validators.every(({ property, type, validator }) => { + const types = type.split('|'); + const propertyString = String(property); + + const valid = + isValidObject(object) && + types.some( + (_type) => + typeof object[propertyString as keyof typeof object] === _type, + ) && + (!validator || validator(object[propertyString as keyof typeof object])); + + if (!valid && logError) { + const value = isValidObject(object) + ? object[propertyString as keyof typeof object] + : undefined; + const typeString = isValidObject(object) + ? typeof object[propertyString as keyof typeof object] + : 'undefined'; + + console.error( + `response to GET ${urlUsed} invalid for property ${String(property)}; value was:`, + value, + '| type was: ', + typeString, + ); + } + return valid; + }); +}; + +export const validateResponse = ( + validators: Validator[], + data: unknown, + urlUsed: string, +): data is ExpectedResponse => { + if (data === null || data === undefined) { + return false; + } + return validateData(validators, data, urlUsed); +}; + +const assetValidators = [ + { + property: 'chainId', + type: 'number', + validator: (v: unknown): v is number => typeof v === 'number', + }, + { + property: 'address', + type: 'string', + validator: (v: unknown): v is string => isValidHexAddress(v as string), + }, + { + property: 'symbol', + type: 'string', + validator: (v: unknown): v is string => typeof v === 'string', + }, + { + property: 'name', + type: 'string', + validator: (v: unknown): v is string => typeof v === 'string', + }, + { + property: 'decimals', + type: 'number', + validator: (v: unknown): v is number => typeof v === 'number', + }, + { + property: 'icon', + // typeof null === 'object' + type: 'string|undefined|object', + validator: (v: unknown): v is string | undefined | object => + v === undefined || v === null || typeof v === 'string', + }, +]; + +const assetValidator = (v: unknown): v is Asset => + validateResponse(assetValidators, v, BRIDGE_STATUS_BASE_URL); + +const srcChainStatusValidators = [ + { + property: 'chainId', + // For some reason, API returns destChain.chainId as a string, it's a number everywhere else + type: 'number|string', + validator: (v: unknown): v is number | string => + typeof v === 'number' || typeof v === 'string', + }, + { + property: 'txHash', + type: 'string', + validator: validHex, + }, + { + property: 'amount', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'token', + type: 'object|undefined', + validator: (v: unknown): v is object | undefined => + v === undefined || + (v && typeof v === 'object' && Object.keys(v).length === 0) || + assetValidator(v), + }, +]; + +const srcChainStatusValidator = (v: unknown): v is SrcChainStatus => + validateResponse( + srcChainStatusValidators, + v, + BRIDGE_STATUS_BASE_URL, + ); + +const destChainStatusValidators = [ + { + property: 'chainId', + // For some reason, API returns destChain.chainId as a string, it's a number everywhere else + type: 'number|string', + validator: (v: unknown): v is number | string => + typeof v === 'number' || typeof v === 'string', + }, + { + property: 'amount', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'txHash', + type: 'string|undefined', + validator: (v: unknown): v is string | undefined => + v === undefined || typeof v === 'string', + }, + { + property: 'token', + type: 'object|undefined', + validator: (v: unknown): v is Asset | undefined => + v === undefined || + (v && typeof v === 'object' && Object.keys(v).length === 0) || + assetValidator(v), + }, +]; + +const destChainStatusValidator = (v: unknown): v is DestChainStatus => + validateResponse( + destChainStatusValidators, + v, + BRIDGE_STATUS_BASE_URL, + ); + +export const validators = [ + { + property: 'status', + type: 'string', + validator: (v: unknown): v is StatusTypes => + Object.values(StatusTypes).includes(v as StatusTypes), + }, + { + property: 'srcChain', + type: 'object', + validator: srcChainStatusValidator, + }, + { + property: 'destChain', + type: 'object|undefined', + validator: (v: unknown): v is object | unknown => + v === undefined || destChainStatusValidator(v), + }, + { + property: 'bridge', + type: 'string|undefined', + validator: (v: unknown): v is BridgeId | undefined => + v === undefined || Object.values(BridgeId).includes(v as BridgeId), + }, + { + property: 'isExpectedToken', + type: 'boolean|undefined', + validator: (v: unknown): v is boolean | undefined => + v === undefined || typeof v === 'boolean', + }, + { + property: 'isUnrecognizedRouterAddress', + type: 'boolean|undefined', + validator: (v: unknown): v is boolean | undefined => + v === undefined || typeof v === 'boolean', + }, + // TODO: add refuel validator + // { + // property: 'refuel', + // type: 'object', + // validator: (v: unknown) => Object.values(RefuelStatusResponse).includes(v), + // }, +]; diff --git a/packages/bridge-status-controller/tsconfig.build.json b/packages/bridge-status-controller/tsconfig.build.json new file mode 100644 index 00000000000..817b522d1ed --- /dev/null +++ b/packages/bridge-status-controller/tsconfig.build.json @@ -0,0 +1,18 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../accounts-controller/tsconfig.build.json" }, + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../bridge-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../transaction-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-status-controller/tsconfig.json b/packages/bridge-status-controller/tsconfig.json new file mode 100644 index 00000000000..97995227d3e --- /dev/null +++ b/packages/bridge-status-controller/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./", + "resolveJsonModule": true + }, + "references": [ + { "path": "../accounts-controller" }, + { "path": "../base-controller" }, + { "path": "../bridge-controller" }, + { "path": "../controller-utils" }, + { "path": "../network-controller" }, + { "path": "../polling-controller" }, + { "path": "../transaction-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/bridge-status-controller/typedoc.json b/packages/bridge-status-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/bridge-status-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 427e514be97..79bfbe604e0 100644 --- a/teams.json +++ b/teams.json @@ -6,6 +6,7 @@ "metamask/assets-controllers": "team-assets", "metamask/base-controller": "team-wallet-framework", "metamask/bridge-controller": "team-swaps,team-bridge", + "metamask/bridge-status-controller": "team-swaps,team-bridge", "metamask/build-utils": "team-wallet-framework", "metamask/composable-controller": "team-wallet-framework", "metamask/controller-utils": "team-wallet-framework", diff --git a/tsconfig.build.json b/tsconfig.build.json index a091abb09e7..2894d71c199 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -6,6 +6,8 @@ { "path": "./packages/approval-controller/tsconfig.build.json" }, { "path": "./packages/assets-controllers/tsconfig.build.json" }, { "path": "./packages/base-controller/tsconfig.build.json" }, + { "path": "./packages/bridge-controller/tsconfig.build.json" }, + { "path": "./packages/bridge-status-controller/tsconfig.build.json" }, { "path": "./packages/build-utils/tsconfig.build.json" }, { "path": "./packages/composable-controller/tsconfig.build.json" }, { "path": "./packages/controller-utils/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 489ba07d2a9..1271d8f2ed7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -13,6 +13,8 @@ { "path": "./packages/approval-controller" }, { "path": "./packages/assets-controllers" }, { "path": "./packages/base-controller" }, + { "path": "./packages/bridge-controller" }, + { "path": "./packages/bridge-status-controller" }, { "path": "./packages/build-utils" }, { "path": "./packages/composable-controller" }, { "path": "./packages/controller-utils" }, diff --git a/yarn.lock b/yarn.lock index 86a3b4cfc3a..149e56a113e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2582,7 +2582,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^0.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -2591,7 +2591,6 @@ __metadata: "@metamask/base-controller": "npm:^8.0.0" "@metamask/controller-utils": "npm:^11.5.0" "@metamask/eth-json-rpc-provider": "npm:^4.1.8" - "@metamask/json-rpc-engine": "npm:^10.0.3" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^22.2.1" "@metamask/polling-controller": "npm:^12.0.3" @@ -2615,6 +2614,37 @@ __metadata: languageName: unknown linkType: soft +"@metamask/bridge-status-controller@workspace:packages/bridge-status-controller": + version: 0.0.0-use.local + resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" + dependencies: + "@metamask/accounts-controller": "npm:^24.0.0" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.0.0" + "@metamask/bridge-controller": "npm:^0.0.0" + "@metamask/controller-utils": "npm:^11.5.0" + "@metamask/network-controller": "npm:^22.2.1" + "@metamask/polling-controller": "npm:^12.0.3" + "@metamask/transaction-controller": "npm:^46.0.0" + "@metamask/utils": "npm:^11.2.0" + "@types/jest": "npm:^27.4.1" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + jest-environment-jsdom: "npm:^27.5.1" + lodash: "npm:^4.17.21" + nock: "npm:^13.3.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + peerDependencies: + "@metamask/accounts-controller": ^24.0.0 + "@metamask/bridge-controller": ^0.0.0 + "@metamask/network-controller": ^22.0.0 + "@metamask/transaction-controller": ^46.0.0 + languageName: unknown + linkType: soft + "@metamask/browser-passworder@npm:^4.3.0": version: 4.3.0 resolution: "@metamask/browser-passworder@npm:4.3.0"