diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 59861240bad..c79e21c7495 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -27,6 +27,7 @@ /packages/name-controller @MetaMask/confirmations /packages/signature-controller @MetaMask/confirmations /packages/transaction-controller @MetaMask/confirmations +/packages/transaction-pay-controller @MetaMask/confirmations /packages/user-operation-controller @MetaMask/confirmations ## Delegation Team @@ -147,6 +148,8 @@ /packages/signature-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/transaction-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/user-operation-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/user-operation-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/multichain-transactions-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index 0bcff1fe4ec..09f412b7d6c 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -79,6 +79,7 @@ "@metamask/gas-fee-controller": "^24.1.0", "@metamask/network-controller": "^24.2.1", "@metamask/remote-feature-flag-controller": "^1.8.0", + "@ts-bridge/cli": "^0.6.1", "@types/bn.js": "^5.1.5", "@types/jest": "^27.4.1", "@types/node": "^16.18.54", diff --git a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts index 316866f56c6..9f1d4a7b503 100644 --- a/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts +++ b/packages/transaction-controller/src/helpers/PendingTransactionTracker.ts @@ -382,7 +382,12 @@ export class PendingTransactionTracker { } async #checkTransaction(txMeta: TransactionMeta) { - const { hash, id } = txMeta; + const { hash, id, isIntentComplete } = txMeta; + + if (isIntentComplete) { + await this.#onTransactionConfirmed(txMeta); + return; + } if (!hash && (await this.#beforeCheckPendingTransaction(txMeta))) { const error = new Error( @@ -450,10 +455,10 @@ export class PendingTransactionTracker { async #onTransactionConfirmed( txMeta: TransactionMeta, - receipt: SuccessfulTransactionReceipt, + receipt?: SuccessfulTransactionReceipt, ) { const { id } = txMeta; - const { blockHash } = receipt; + const { blockHash } = receipt ?? {}; this.#log('Transaction confirmed', id); @@ -463,19 +468,23 @@ export class PendingTransactionTracker { return; } - const { baseFeePerGas, timestamp: blockTimestamp } = - await this.#getBlockByHash(blockHash, false); - const updatedTxMeta = cloneDeep(txMeta); - updatedTxMeta.baseFeePerGas = baseFeePerGas; - updatedTxMeta.blockTimestamp = blockTimestamp; + + if (receipt && blockHash) { + const { baseFeePerGas, timestamp: blockTimestamp } = + await this.#getBlockByHash(blockHash, false); + + updatedTxMeta.baseFeePerGas = baseFeePerGas; + updatedTxMeta.blockTimestamp = blockTimestamp; + updatedTxMeta.txParams = { + ...updatedTxMeta.txParams, + gasUsed: receipt.gasUsed, + }; + updatedTxMeta.txReceipt = receipt; + updatedTxMeta.verifiedOnBlockchain = true; + } + updatedTxMeta.status = TransactionStatus.confirmed; - updatedTxMeta.txParams = { - ...updatedTxMeta.txParams, - gasUsed: receipt.gasUsed, - }; - updatedTxMeta.txReceipt = receipt; - updatedTxMeta.verifiedOnBlockchain = true; this.#updateTransaction( updatedTxMeta, diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 04abfb35e59..808b3556395 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -268,6 +268,9 @@ export type TransactionMeta = { /** Whether MetaMask will be compensated for the gas fee by the transaction. */ isGasFeeIncluded?: boolean; + /** Whether the intent of the transaction was achieved via an alternate route or chain. */ + isIntentComplete?: boolean; + /** * Whether the transaction is an incoming token transfer. */ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md new file mode 100644 index 00000000000..2c5ddb5ab25 --- /dev/null +++ b/packages/transaction-pay-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 ([#6820](https://github.com/MetaMask/core/pull/6820)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/transaction-pay-controller/LICENSE b/packages/transaction-pay-controller/LICENSE new file mode 100644 index 00000000000..7d002dced3a --- /dev/null +++ b/packages/transaction-pay-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/transaction-pay-controller/README.md b/packages/transaction-pay-controller/README.md new file mode 100644 index 00000000000..c98613c0ef7 --- /dev/null +++ b/packages/transaction-pay-controller/README.md @@ -0,0 +1,19 @@ +# `@metamask/transaction-pay-controller` + +Manages alternate payment strategies to provide required funds for transactions in MetaMask. + +## Installation + +`yarn add @metamask/transaction-pay-controller` + +or + +`npm install @metamask/transaction-pay-controller` + +## Compatibility + +This package relies implicitly upon the `EventEmitter` module. This module is available natively in Node.js, but when using this package for the browser, make sure to use a polyfill such as `events`. + +## 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/transaction-pay-controller/jest.config.js b/packages/transaction-pay-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/transaction-pay-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: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json new file mode 100644 index 00000000000..68a1b9cb8b8 --- /dev/null +++ b/packages/transaction-pay-controller/package.json @@ -0,0 +1,94 @@ +{ + "name": "@metamask/transaction-pay-controller", + "version": "0.0.0", + "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/transaction-pay-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/transaction-pay-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/transaction-pay-controller", + "prepare-manifest:preview": "../../scripts/prepare-preview-manifest.sh", + "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": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@metamask/base-controller": "^8.4.1", + "@metamask/controller-utils": "^11.14.1", + "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/utils": "^11.8.1", + "bignumber.js": "^9.1.2", + "bn.js": "^5.2.1", + "immer": "^9.0.6", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/assets-controllers": "^79.0.1", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/bridge-controller": "^49.0.1", + "@metamask/bridge-status-controller": "^49.0.1", + "@metamask/network-controller": "^24.2.1", + "@metamask/remote-feature-flag-controller": "^1.8.0", + "@metamask/transaction-controller": "^60.6.1", + "@ts-bridge/cli": "^0.6.1", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "peerDependencies": { + "@metamask/assets-controllers": "^79.0.0", + "@metamask/bridge-controller": "^49.0.0", + "@metamask/bridge-status-controller": "^49.0.0", + "@metamask/network-controller": "^24.0.0", + "@metamask/remote-feature-flag-controller": "^1.5.0", + "@metamask/transaction-controller": "^60.0.0" + }, + "engines": { + "node": "^18.18 || >=20" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts new file mode 100644 index 00000000000..556a9bcf45b --- /dev/null +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -0,0 +1,158 @@ +import { Messenger } from '@metamask/base-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { TransactionPayControllerMessenger } from '.'; +import { TransactionPayController } from '.'; +import { updatePaymentToken } from './actions/update-payment-token'; +import { TransactionPayStrategy } from './constants'; +import type { SourceAmountValues } from './types'; +import { updateQuotes } from './utils/quotes'; +import { updateSourceAmounts } from './utils/source-amounts'; + +jest.mock('./actions/update-payment-token'); +jest.mock('./utils/source-amounts'); +jest.mock('./utils/quotes'); + +const TRANSACTION_ID_MOCK = '123-456'; +const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; +const TOKEN_ADDRESS_MOCK = '0xabc' as Hex; +const CHAIN_ID_MOCK = '0x1' as Hex; + +describe('TransactionPayController', () => { + const updatePaymentTokenMock = jest.mocked(updatePaymentToken); + const updateSourceAmountsMock = jest.mocked(updateSourceAmounts); + const updateQuotesMock = jest.mocked(updateQuotes); + let messenger: TransactionPayControllerMessenger; + + /** + * Create a TransactionPayController. + * + * @returns The created controller. + */ + function createController() { + return new TransactionPayController({ + messenger, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + messenger = new Messenger() as unknown as TransactionPayControllerMessenger; + + updateQuotesMock.mockResolvedValue(); + }); + + describe('updatePaymentToken', () => { + it('calls util', () => { + createController().updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + expect(updatePaymentTokenMock).toHaveBeenCalledWith( + { + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }, + { + messenger, + updateTransactionData: expect.any(Function), + }, + ); + }); + }); + + describe('getStrategy Action', () => { + it('returns relay if no callback', async () => { + createController(); + + expect( + await messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Relay); + }); + + it('returns callback value if provided', async () => { + new TransactionPayController({ + getStrategy: async () => TransactionPayStrategy.Test, + messenger, + }); + + expect( + await messenger.call( + 'TransactionPayController:getStrategy', + TRANSACTION_META_MOCK, + ), + ).toBe(TransactionPayStrategy.Test); + }); + }); + + describe('updateTransactionData', () => { + it('updates state', () => { + const controller = createController(); + + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.sourceAmounts = [ + { sourceAmountHuman: '1.23' } as SourceAmountValues, + ]; + }); + + expect( + controller.state.transactionData[TRANSACTION_ID_MOCK], + ).toStrictEqual({ + isLoading: true, + sourceAmounts: [{ sourceAmountHuman: '1.23' }], + tokens: [], + }); + }); + + it('updates source amounts and quotes', () => { + const controller = createController(); + + controller.updatePaymentToken({ + transactionId: TRANSACTION_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + chainId: CHAIN_ID_MOCK, + }); + + const { updateTransactionData } = updatePaymentTokenMock.mock.calls[0][1]; + + updateTransactionData(TRANSACTION_ID_MOCK, (data) => { + data.sourceAmounts = [ + { sourceAmountHuman: '1.23' } as SourceAmountValues, + ]; + }); + + expect(updateSourceAmountsMock).toHaveBeenCalledWith( + TRANSACTION_ID_MOCK, + expect.objectContaining({ + sourceAmounts: [{ sourceAmountHuman: '1.23' }], + }), + messenger, + ); + + expect(updateQuotesMock).toHaveBeenCalledWith({ + messenger, + transactionData: expect.objectContaining({ + sourceAmounts: [{ sourceAmountHuman: '1.23' }], + }), + transactionId: TRANSACTION_ID_MOCK, + updateTransactionData: expect.any(Function), + }); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts new file mode 100644 index 00000000000..d4488953063 --- /dev/null +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -0,0 +1,129 @@ +import { BaseController } from '@metamask/base-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import type { Draft } from 'immer'; +import { noop } from 'lodash'; + +import { updatePaymentToken } from './actions/update-payment-token'; +import { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; +import type { + TransactionData, + TransactionPayControllerMessenger, + TransactionPayControllerOptions, + TransactionPayControllerState, +} from './types'; +import { updateQuotes } from './utils/quotes'; +import { updateSourceAmounts } from './utils/source-amounts'; +import { pollTransactionChanges } from './utils/transaction'; + +const stateMetadata = { + transactionData: { persist: false, anonymous: false }, +}; + +const getDefaultState = () => ({ + transactionData: {}, +}); + +export class TransactionPayController extends BaseController< + typeof CONTROLLER_NAME, + TransactionPayControllerState, + TransactionPayControllerMessenger +> { + readonly #getStrategy?: ( + transaction: TransactionMeta, + ) => Promise; + + constructor({ + getStrategy, + messenger, + state, + }: TransactionPayControllerOptions) { + super({ + name: CONTROLLER_NAME, + metadata: stateMetadata, + messenger, + state: { ...getDefaultState(), ...state }, + }); + + this.#getStrategy = getStrategy; + + this.#registerActionHandlers(); + + pollTransactionChanges(messenger, this.#updateTransactionData.bind(this)); + } + + updatePaymentToken({ + transactionId, + tokenAddress, + chainId, + }: { + transactionId: string; + tokenAddress: Hex; + chainId: Hex; + }) { + updatePaymentToken( + { transactionId, tokenAddress, chainId }, + { + messenger: this.messagingSystem, + updateTransactionData: this.#updateTransactionData.bind(this), + }, + ); + } + + #updateTransactionData( + transactionId: string, + fn: (transactionData: Draft) => void, + ) { + let shouldUpdateQuotes = false; + + this.update((state) => { + const { transactionData } = state; + let current = transactionData[transactionId]; + const originalPaymentToken = current?.paymentToken; + const originalTokens = current?.tokens; + + if (!current) { + transactionData[transactionId] = { + isLoading: false, + tokens: [], + }; + + current = transactionData[transactionId]; + } + + fn(current); + + const isPaymentTokenUpdated = + current.paymentToken !== originalPaymentToken; + + const isTokensUpdated = current.tokens !== originalTokens; + + if (isPaymentTokenUpdated || isTokensUpdated) { + updateSourceAmounts( + transactionId, + current as never, + this.messagingSystem, + ); + + shouldUpdateQuotes = true; + current.isLoading = true; + } + }); + + if (shouldUpdateQuotes) { + updateQuotes({ + messenger: this.messagingSystem, + transactionData: this.state.transactionData[transactionId], + transactionId, + updateTransactionData: this.#updateTransactionData.bind(this), + }).catch(noop); + } + } + + #registerActionHandlers() { + this.messagingSystem.registerActionHandler( + 'TransactionPayController:getStrategy', + this.#getStrategy ?? (async () => TransactionPayStrategy.Relay), + ); + } +} diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts new file mode 100644 index 00000000000..8b0d24dfc9e --- /dev/null +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.test.ts @@ -0,0 +1,125 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { noop } from 'lodash'; + +import { updatePaymentToken } from './update-payment-token'; +import type { TransactionData } from '../types'; +import { + getTokenBalance, + getTokenInfo, + getTokenFiatRate, +} from '../utils/token'; +import { getTransaction } from '../utils/transaction'; + +jest.mock('../utils/token'); +jest.mock('../utils/transaction'); + +const TOKEN_ADDRESS_MOCK = '0x123'; +const CHAIN_ID_MOCK = '0x1'; +const FROM_MOCK = '0x456'; +const TRANSACTION_ID_MOCK = '123-456'; + +describe('Update Payment Token Action', () => { + const getTokenBalanceMock = jest.mocked(getTokenBalance); + const getTokenInfoMock = jest.mocked(getTokenInfo); + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getTransactionMock = jest.mocked(getTransaction); + + beforeEach(() => { + jest.resetAllMocks(); + + getTokenInfoMock.mockReturnValue({ decimals: 6, symbol: 'TST' }); + getTokenBalanceMock.mockReturnValue('1230000'); + getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); + + getTransactionMock.mockReturnValue({ + id: TRANSACTION_ID_MOCK, + txParams: { from: FROM_MOCK }, + } as TransactionMeta); + }); + + it('updates payment token', () => { + const updateTransactionDataMock = jest.fn(); + + updatePaymentToken( + { + chainId: CHAIN_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + transactionId: TRANSACTION_ID_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: updateTransactionDataMock, + }, + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + + const transactionDataMock = {} as TransactionData; + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock.paymentToken).toStrictEqual({ + address: TOKEN_ADDRESS_MOCK, + balanceFiat: '2.46', + balanceHuman: '1.23', + balanceRaw: '1230000', + balanceUsd: '3.69', + chainId: CHAIN_ID_MOCK, + decimals: 6, + symbol: 'TST', + }); + }); + + it('throws if decimals not found', () => { + getTokenInfoMock.mockReturnValue(undefined); + + expect(() => + updatePaymentToken( + { + chainId: CHAIN_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + transactionId: TRANSACTION_ID_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: noop, + }, + ), + ).toThrow('Payment token not found'); + }); + + it('throws if token fiat rate not found', () => { + getTokenFiatRateMock.mockReturnValue(undefined); + + expect(() => + updatePaymentToken( + { + chainId: CHAIN_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + transactionId: TRANSACTION_ID_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: noop, + }, + ), + ).toThrow('Payment token not found'); + }); + + it('throws if transaction not found', () => { + getTransactionMock.mockReturnValue(undefined); + + expect(() => + updatePaymentToken( + { + chainId: CHAIN_ID_MOCK, + tokenAddress: TOKEN_ADDRESS_MOCK, + transactionId: TRANSACTION_ID_MOCK, + }, + { + messenger: {} as never, + updateTransactionData: noop, + }, + ), + ).toThrow('Transaction not found'); + }); +}); diff --git a/packages/transaction-pay-controller/src/actions/update-payment-token.ts b/packages/transaction-pay-controller/src/actions/update-payment-token.ts new file mode 100644 index 00000000000..5224c4a0869 --- /dev/null +++ b/packages/transaction-pay-controller/src/actions/update-payment-token.ts @@ -0,0 +1,125 @@ +import { createModuleLogger, type Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { TransactionPayControllerMessenger } from '..'; +import { projectLogger } from '../logger'; +import type { + TransactionPaymentToken, + UpdateTransactionDataCallback, +} from '../types'; +import { + getTokenBalance, + getTokenFiatRate, + getTokenInfo, +} from '../utils/token'; +import { getTransaction } from '../utils/transaction'; + +const log = createModuleLogger(projectLogger, 'update-payment-token'); + +export type UpdatePaymentTokenRequest = { + transactionId: string; + tokenAddress: Hex; + chainId: Hex; +}; + +export type UpdatePaymentTokenOptions = { + messenger: TransactionPayControllerMessenger; + updateTransactionData: UpdateTransactionDataCallback; +}; + +/** + * Update the payment token for a specific transaction. + * + * @param request - Request parameters. + * @param options - Options bag. + */ +export function updatePaymentToken( + request: UpdatePaymentTokenRequest, + options: UpdatePaymentTokenOptions, +) { + const { transactionId, tokenAddress, chainId } = request; + const { messenger, updateTransactionData } = options; + + const transaction = getTransaction(transactionId, messenger); + + if (!transaction) { + throw new Error('Transaction not found'); + } + + const paymentToken = getPaymentToken({ + chainId, + from: transaction?.txParams.from as Hex, + messenger, + tokenAddress, + }); + + if (!paymentToken) { + throw new Error('Payment token not found'); + } + + log('Updated payment token', { transactionId, paymentToken }); + + updateTransactionData(transactionId, (data) => { + data.paymentToken = paymentToken; + }); +} + +/** + * Generate the full payment token data from a token address and chain ID. + * + * @param request - The payment token request parameters. + * @param request.chainId - The chain ID. + * @param request.from - The address to get the token balance for. + * @param request.messenger - The transaction pay controller messenger. + * @param request.tokenAddress - The token address. + * @returns The payment token or undefined if the token data could not be retrieved. + */ +function getPaymentToken({ + chainId, + from, + messenger, + tokenAddress, +}: { + chainId: Hex; + from: Hex; + messenger: TransactionPayControllerMessenger; + tokenAddress: Hex; +}): TransactionPaymentToken | undefined { + const { decimals, symbol } = + getTokenInfo(messenger, tokenAddress, chainId) ?? {}; + + if (decimals === undefined || !symbol) { + return undefined; + } + + const tokenFiatRate = getTokenFiatRate(messenger, tokenAddress, chainId); + + if (tokenFiatRate === undefined) { + return undefined; + } + + const balance = getTokenBalance(messenger, from, chainId, tokenAddress); + const balanceRawValue = new BigNumber(balance); + const balanceHumanValue = new BigNumber(balance).shiftedBy(-decimals); + const balanceRaw = balanceRawValue.toFixed(0); + const balanceHuman = balanceHumanValue.toString(10); + + const balanceFiat = balanceHumanValue + .multipliedBy(tokenFiatRate.fiatRate) + .toString(10); + + const balanceUsd = balanceHumanValue + .multipliedBy(tokenFiatRate.usdRate) + .toString(10); + + return { + address: tokenAddress, + balanceFiat, + balanceHuman, + balanceRaw, + balanceUsd, + chainId, + decimals, + symbol, + }; +} diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts new file mode 100644 index 00000000000..0b587755bd7 --- /dev/null +++ b/packages/transaction-pay-controller/src/constants.ts @@ -0,0 +1,7 @@ +export const CONTROLLER_NAME = 'TransactionPayController'; + +export enum TransactionPayStrategy { + Bridge = 'bridge', + Relay = 'relay', + Test = 'test', +} diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts new file mode 100644 index 00000000000..1e1e842ce18 --- /dev/null +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.test.ts @@ -0,0 +1,108 @@ +import { Messenger } from '@metamask/base-controller'; +import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { TransactionControllerUnapprovedTransactionAddedEvent } from '@metamask/transaction-controller'; + +import { TransactionPayPublishHook } from './TransactionPayPublishHook'; +import { TransactionPayStrategy } from '../constants'; +import { TestStrategy } from '../strategy/test/TestStrategy'; +import type { + TransactionPayControllerGetStateAction, + TransactionPayPublishHookMessenger, + TransactionPayQuote, +} from '../types'; + +jest.mock('../strategy/test/TestStrategy'); + +const TRANSACTION_META_MOCK = { + id: '123-456', + txParams: { + from: '0xabc', + }, +} as TransactionMeta; + +const QUOTE_MOCK = {} as TransactionPayQuote; + +describe('TransactionPayPublishHook', () => { + const isSmartTransactionMock = jest.fn(); + const getControllerStateMock = jest.fn(); + const executeMock = jest.fn(); + + let messenger: TransactionPayPublishHookMessenger; + let hook: TransactionPayPublishHook; + + /** + * Run the publish hook. + * + * @returns The result of the publish hook. + */ + function runHook() { + return hook.getHook()(TRANSACTION_META_MOCK, '0x1234'); + } + + beforeEach(() => { + jest.resetAllMocks(); + + messenger = new Messenger< + BridgeStatusControllerActions | TransactionPayControllerGetStateAction, + | BridgeStatusControllerStateChangeEvent + | TransactionControllerUnapprovedTransactionAddedEvent + >(); + + hook = new TransactionPayPublishHook({ + isSmartTransaction: isSmartTransactionMock, + messenger, + }); + + messenger.registerActionHandler('TransactionPayController:getState', () => + getControllerStateMock(), + ); + + messenger.registerActionHandler( + 'TransactionPayController:getStrategy', + async () => TransactionPayStrategy.Test, + ); + + jest.mocked(TestStrategy).mockReturnValue({ + execute: executeMock, + getQuotes: jest.fn(), + } as unknown as TestStrategy); + + isSmartTransactionMock.mockReturnValue(false); + + getControllerStateMock.mockReturnValue({ + transactionData: { + [TRANSACTION_META_MOCK.id]: { + quotes: [QUOTE_MOCK, QUOTE_MOCK], + }, + }, + }); + }); + + it('executes strategy with quotes', async () => { + await runHook(); + + expect(executeMock).toHaveBeenCalledWith( + expect.objectContaining({ + quotes: [QUOTE_MOCK, QUOTE_MOCK], + }), + ); + }); + + it('does nothing if no quotes in state', async () => { + getControllerStateMock.mockReturnValue({ + transactionData: {}, + }); + + await runHook(); + + expect(executeMock).not.toHaveBeenCalled(); + }); + + it('throws errors from submit', async () => { + executeMock.mockRejectedValue(new Error('Test error')); + + await expect(runHook()).rejects.toThrow('Test error'); + }); +}); diff --git a/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts new file mode 100644 index 00000000000..6910331fa7c --- /dev/null +++ b/packages/transaction-pay-controller/src/helpers/TransactionPayPublishHook.ts @@ -0,0 +1,80 @@ +import type { PublishHook } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { PublishHookResult } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../logger'; +import type { + TransactionPayPublishHookMessenger, + TransactionPayQuote, +} from '../types'; +import { getStrategy } from '../utils/strategy'; + +const log = createModuleLogger(projectLogger, 'pay-publish-hook'); + +const EMPTY_RESULT = { + transactionHash: undefined, +}; + +export class TransactionPayPublishHook { + readonly #isSmartTransaction: (chainId: Hex) => boolean; + + readonly #messenger: TransactionPayPublishHookMessenger; + + constructor({ + isSmartTransaction, + messenger, + }: { + isSmartTransaction: (chainId: Hex) => boolean; + messenger: TransactionPayPublishHookMessenger; + }) { + this.#isSmartTransaction = isSmartTransaction; + this.#messenger = messenger; + } + + getHook(): PublishHook { + return this.#hookWrapper.bind(this); + } + + async #hookWrapper( + transactionMeta: TransactionMeta, + _signedTx: string, + ): Promise { + try { + return await this.#publishHook(transactionMeta, _signedTx); + } catch (error) { + log('Error', error); + throw error; + } + } + + async #publishHook( + transactionMeta: TransactionMeta, + _signedTx: string, + ): Promise { + const { id: transactionId } = transactionMeta; + + const controllerState = this.#messenger.call( + 'TransactionPayController:getState', + ); + + const quotes = + (controllerState.transactionData?.[transactionId] + ?.quotes as TransactionPayQuote[]) ?? []; + + if (!quotes?.length) { + log('Skipping as no quotes found'); + return EMPTY_RESULT; + } + + const strategy = await getStrategy(this.#messenger, transactionMeta); + + return await strategy.execute({ + isSmartTransaction: this.#isSmartTransaction, + quotes, + messenger: this.#messenger, + transaction: transactionMeta, + }); + } +} diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts new file mode 100644 index 00000000000..61af7d41e01 --- /dev/null +++ b/packages/transaction-pay-controller/src/index.ts @@ -0,0 +1,13 @@ +export type { + TransactionPayControllerGetStateAction, + TransactionPayControllerMessenger, + TransactionPayControllerState, + TransactionPayControllerStateChangeEvent, + TransactionPaymentToken, + TransactionPayPublishHookMessenger, + TransactionPayQuote, + TransactionPayTotals, + TransactionToken, +} from './types'; +export { TransactionPayController } from './TransactionPayController'; +export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook'; diff --git a/packages/transaction-pay-controller/src/logger.ts b/packages/transaction-pay-controller/src/logger.ts new file mode 100644 index 00000000000..31483fa2a14 --- /dev/null +++ b/packages/transaction-pay-controller/src/logger.ts @@ -0,0 +1,7 @@ +/* istanbul ignore file */ + +import { createProjectLogger, createModuleLogger } from '@metamask/utils'; + +export const projectLogger = createProjectLogger('transaction-pay-controller'); + +export { createModuleLogger }; diff --git a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts new file mode 100644 index 00000000000..c3006c683a8 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.test.ts @@ -0,0 +1,53 @@ +import type { QuoteResponse } from '@metamask/bridge-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { getBridgeQuotes } from './bridge-quotes'; +import { submitBridgeQuotes } from './bridge-submit'; +import { BridgeStrategy } from './BridgeStrategy'; +import type { + TransactionPayControllerMessenger, + TransactionPayPublishHookMessenger, +} from '../..'; +import type { TransactionPayQuote } from '../../types'; + +jest.mock('./bridge-quotes'); +jest.mock('./bridge-submit'); + +const QUOTE_MOCK = { + estimatedDuration: 5, +} as TransactionPayQuote; + +describe('BridgeStrategy', () => { + const getBridgeQuotesMock = jest.mocked(getBridgeQuotes); + const submitBridgeQuotesMock = jest.mocked(submitBridgeQuotes); + + beforeEach(() => { + jest.resetAllMocks(); + getBridgeQuotesMock.mockResolvedValue([QUOTE_MOCK]); + }); + + describe('getQuotes', () => { + it('returns result from util', async () => { + const result = new BridgeStrategy().getQuotes({ + messenger: {} as TransactionPayControllerMessenger, + requests: [], + }); + + expect(await result).toStrictEqual([QUOTE_MOCK]); + }); + }); + + describe('execute', () => { + it('calls util', async () => { + await new BridgeStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayPublishHookMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(submitBridgeQuotesMock).toHaveBeenCalledTimes(1); + expect(submitBridgeQuotesMock.mock.calls[0][0].from).toBe('0x1'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts new file mode 100644 index 00000000000..735489d399d --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/BridgeStrategy.ts @@ -0,0 +1,34 @@ +import type { QuoteResponse } from '@metamask/bridge-controller'; +import type { Hex } from '@metamask/utils'; +import { noop } from 'lodash'; + +import { getBridgeQuotes } from './bridge-quotes'; +import { submitBridgeQuotes } from './bridge-submit'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, +} from '../../types'; + +export class BridgeStrategy implements PayStrategy { + async getQuotes(request: PayStrategyGetQuotesRequest) { + const { messenger, requests } = request; + + return getBridgeQuotes(requests, messenger); + } + + async execute(request: PayStrategyExecuteRequest) { + const { isSmartTransaction, quotes, messenger, transaction } = request; + const from = transaction.txParams.from as Hex; + + await submitBridgeQuotes({ + from, + isSmartTransaction, + messenger, + quotes, + updateTransaction: noop, + }); + + return { transactionHash: undefined }; + } +} diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts new file mode 100644 index 00000000000..e569add3948 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.test.ts @@ -0,0 +1,681 @@ +import { Messenger } from '@metamask/base-controller'; +import type { + BridgeController, + QuoteResponse, +} from '@metamask/bridge-controller'; + +import { getBridgeQuotes } from './bridge-quotes'; +import type { AllowedActions } from '../../types'; +import { + type QuoteRequest, + type TransactionPayControllerMessenger, +} from '../../types'; +import { getTokenFiatRate } from '../../utils/token'; + +jest.mock('../../utils/token'); + +jest.useFakeTimers(); + +const QUOTE_REQUEST_1_MOCK: QuoteRequest = { + from: '0x123', + sourceBalanceRaw: '10000000000000000000', + sourceChainId: '0x1', + sourceTokenAddress: '0xabc', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '123', + targetChainId: '0x2', + targetTokenAddress: '0xdef', +}; + +const QUOTE_REQUEST_2_MOCK: QuoteRequest = { + ...QUOTE_REQUEST_1_MOCK, + targetTokenAddress: '0x456', +}; + +const QUOTE_1_MOCK = { + estimatedProcessingTimeInSeconds: 40, + quote: { + destAsset: { + address: QUOTE_REQUEST_1_MOCK.targetTokenAddress, + decimals: 1, + }, + destChainId: 1, + minDestTokenAmount: '124', + srcAsset: { + address: QUOTE_REQUEST_1_MOCK.sourceTokenAddress, + decimals: 3, + }, + srcChainId: QUOTE_REQUEST_1_MOCK.sourceChainId, + }, +} as unknown as QuoteResponse; + +const QUOTE_2_MOCK = { + quote: { + ...QUOTE_1_MOCK.quote, + destAsset: { + ...QUOTE_1_MOCK.quote.destAsset, + address: QUOTE_REQUEST_2_MOCK.targetTokenAddress, + }, + }, +} as unknown as QuoteResponse; + +const FEATURE_FLAGS_MOCK = { + attemptsMax: 1, + bufferInitial: 1, + bufferStep: 1, + bufferSubsequent: 2, + slippage: 0.005, +}; + +describe('Bridge Quotes Utils', () => { + let bridgeControllerMock: jest.Mocked; + let messengerMock: TransactionPayControllerMessenger; + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + const getFeatureFlagsMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + const baseMessenger = new Messenger(); + + bridgeControllerMock = { + fetchQuotes: jest.fn(), + } as unknown as jest.Mocked; + + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce([QUOTE_1_MOCK]) + .mockResolvedValueOnce([QUOTE_2_MOCK]); + + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '2', + usdRate: '3', + }); + + baseMessenger.registerActionHandler( + 'BridgeController:fetchQuotes', + bridgeControllerMock.fetchQuotes, + ); + + baseMessenger.registerActionHandler( + 'RemoteFeatureFlagController:getState', + () => ({ + cacheTimestamp: 0, + remoteFeatureFlags: { + confirmation_pay: getFeatureFlagsMock(), + }, + }), + ); + + getFeatureFlagsMock.mockReturnValue(FEATURE_FLAGS_MOCK); + + messengerMock = + baseMessenger as unknown as TransactionPayControllerMessenger; + }); + + describe('getBridgeQuotes', () => { + it('returns quotes', async () => { + const quotesPromise = getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK, QUOTE_REQUEST_2_MOCK], + messengerMock, + ); + + const quotes = await quotesPromise; + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTE_1_MOCK), + expect.objectContaining(QUOTE_2_MOCK), + ]); + }); + + it('requests quotes', async () => { + await getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK, QUOTE_REQUEST_2_MOCK], + messengerMock, + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + walletAddress: QUOTE_REQUEST_1_MOCK.from, + srcChainId: QUOTE_REQUEST_1_MOCK.sourceChainId, + srcTokenAddress: QUOTE_REQUEST_1_MOCK.sourceTokenAddress, + srcTokenAmount: QUOTE_REQUEST_1_MOCK.sourceTokenAmount, + destChainId: QUOTE_REQUEST_1_MOCK.targetChainId, + destTokenAddress: QUOTE_REQUEST_1_MOCK.targetTokenAddress, + slippage: 0.5, + insufficientBal: false, + }), + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + walletAddress: QUOTE_REQUEST_2_MOCK.from, + srcChainId: QUOTE_REQUEST_2_MOCK.sourceChainId, + srcTokenAddress: QUOTE_REQUEST_2_MOCK.sourceTokenAddress, + srcTokenAmount: QUOTE_REQUEST_2_MOCK.sourceTokenAmount, + destChainId: QUOTE_REQUEST_2_MOCK.targetChainId, + destTokenAddress: QUOTE_REQUEST_2_MOCK.targetTokenAddress, + slippage: 0.5, + insufficientBal: false, + }), + ); + }); + + it('throws if no quotes', async () => { + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes.mockResolvedValue([]); + + await expect( + getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK, QUOTE_REQUEST_2_MOCK], + messengerMock, + ), + ).rejects.toThrow('No quotes found'); + }); + + it('selects cheapest quote of 3 fastest quotes', async () => { + const QUOTES = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '129', + }, + }, + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 10, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '126', + }, + }, + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 20, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '128', + }, + }, + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 30, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '127', + }, + }, + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 50, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '130', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes.mockResolvedValue(QUOTES); + + const quotes = await getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK], + messengerMock, + ); + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTES[2]), + ]); + }); + + it('throws if all fastest quotes have token amount less than minimum', async () => { + const QUOTES = [ + { + estimatedProcessingTimeInSeconds: 40, + quote: { + minDestTokenAmount: '124', + }, + }, + { + estimatedProcessingTimeInSeconds: 10, + cost: { valueInCurrency: '1.5' }, + quote: { + minDestTokenAmount: '122', + }, + }, + { + estimatedProcessingTimeInSeconds: 20, + cost: { valueInCurrency: '1' }, + quote: { + minDestTokenAmount: '122', + }, + }, + { + estimatedProcessingTimeInSeconds: 30, + cost: { valueInCurrency: '2' }, + quote: { + minDestTokenAmount: '122', + }, + }, + { + estimatedProcessingTimeInSeconds: 50, + cost: { valueInCurrency: '0.9' }, + quote: { + minDestTokenAmount: '124', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes.mockResolvedValue(QUOTES); + + await expect( + getBridgeQuotes([QUOTE_REQUEST_1_MOCK], messengerMock), + ).rejects.toThrow('All quotes under minimum'); + }); + + it('increases source amount until target amount minimum reached', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '122', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '124', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 2, + }); + + const quotes = await getBridgeQuotes( + [{ ...QUOTE_REQUEST_1_MOCK }], + messengerMock, + ); + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTES_ATTEMPT_2[0]), + ]); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(2); + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + }), + ); + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '1500000000000000000', + }), + ); + }); + + it('throws if target amount minimum not reached after max attempts', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '120', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '121', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_3 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '122', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2) + .mockResolvedValueOnce(QUOTES_ATTEMPT_3); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 3, + }); + + await expect( + getBridgeQuotes([{ ...QUOTE_REQUEST_1_MOCK }], messengerMock), + ).rejects.toThrow('All quotes under minimum'); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(3); + }); + + it('throws if target amount minimum not reached and at balance limit', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '120', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '121', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 3, + }); + + await expect( + getBridgeQuotes( + [ + { + ...QUOTE_REQUEST_1_MOCK, + sourceBalanceRaw: '1500000000000000000', + }, + ], + messengerMock, + ), + ).rejects.toThrow('All quotes under minimum'); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(2); + }); + + it('uses balance as source token amount if next amount greater than balance', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '120', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '123', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 3, + }); + + const quotes = await getBridgeQuotes( + [ + { + ...QUOTE_REQUEST_1_MOCK, + sourceBalanceRaw: '1400000000000000000', + }, + ], + messengerMock, + ); + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTES_ATTEMPT_2[0]), + ]); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(2); + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + }), + ); + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + srcTokenAmount: '1400000000000000000', + }), + ); + }); + + it('does not increase source amount if not first request', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '123', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '122', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 3, + }); + + await expect( + getBridgeQuotes( + [ + { + ...QUOTE_REQUEST_1_MOCK, + }, + { + ...QUOTE_REQUEST_2_MOCK, + }, + ], + messengerMock, + ), + ).rejects.toThrow('All quotes under minimum'); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(2); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + }), + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + }), + ); + }); + + it('limits increased source amount to balance minus source amount of subsequent requests', async () => { + const QUOTES_ATTEMPT_1 = [ + { + ...QUOTE_1_MOCK, + estimatedProcessingTimeInSeconds: 40, + quote: { + ...QUOTE_1_MOCK.quote, + minDestTokenAmount: '122', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_2 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '123', + }, + }, + ] as QuoteResponse[]; + + const QUOTES_ATTEMPT_3 = [ + { + ...QUOTES_ATTEMPT_1[0], + quote: { + ...QUOTES_ATTEMPT_1[0].quote, + minDestTokenAmount: '123', + }, + }, + ] as QuoteResponse[]; + + bridgeControllerMock.fetchQuotes.mockReset(); + bridgeControllerMock.fetchQuotes + .mockResolvedValueOnce(QUOTES_ATTEMPT_1) + .mockResolvedValueOnce(QUOTES_ATTEMPT_2) + .mockResolvedValueOnce(QUOTES_ATTEMPT_3); + + getFeatureFlagsMock.mockReturnValue({ + ...FEATURE_FLAGS_MOCK, + attemptsMax: 3, + }); + + const quotes = await getBridgeQuotes( + [ + { + ...QUOTE_REQUEST_1_MOCK, + sourceBalanceRaw: '2400000000000000000', + }, + QUOTE_REQUEST_2_MOCK, + ], + messengerMock, + ); + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTES_ATTEMPT_2[0]), + expect.objectContaining(QUOTES_ATTEMPT_3[0]), + ]); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledTimes(3); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + destTokenAddress: QUOTE_REQUEST_1_MOCK.targetTokenAddress, + }), + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + srcTokenAmount: '1000000000000000000', + destTokenAddress: QUOTE_REQUEST_2_MOCK.targetTokenAddress, + }), + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + srcTokenAmount: '1400000000000000000', + destTokenAddress: QUOTE_REQUEST_1_MOCK.targetTokenAddress, + }), + ); + }); + + it('returns normalized quote', async () => { + const quotes = await getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK], + messengerMock, + ); + + expect(quotes[0]).toMatchObject({ + dust: { + fiat: '0.2', + usd: '0.3', + }, + estimatedDuration: 40, + original: QUOTE_1_MOCK, + request: QUOTE_REQUEST_1_MOCK, + }); + }); + + it('throws if missing fiat rate', async () => { + getTokenFiatRateMock.mockReturnValue(undefined); + + await expect( + getBridgeQuotes([QUOTE_REQUEST_1_MOCK], messengerMock), + ).rejects.toThrow(`Fiat rate not found for source or target token`); + }); + + it('uses defaults if no feature flags', async () => { + getFeatureFlagsMock.mockReturnValue(undefined); + + const quotes = await getBridgeQuotes( + [QUOTE_REQUEST_1_MOCK], + messengerMock, + ); + + expect(bridgeControllerMock.fetchQuotes).toHaveBeenCalledWith( + expect.objectContaining({ + slippage: 0.5, + }), + ); + + expect(quotes.map((q) => q.original)).toStrictEqual([ + expect.objectContaining(QUOTE_1_MOCK), + ]); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts new file mode 100644 index 00000000000..150db8ea769 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-quotes.ts @@ -0,0 +1,410 @@ +import type { GenericQuoteRequest } from '@metamask/bridge-controller'; +import type { QuoteResponse } from '@metamask/bridge-controller'; +import { toChecksumHexAddress, toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { orderBy } from 'lodash'; + +import { projectLogger } from '../../logger'; +import type { + FiatValue, + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; +import { getTokenFiatRate } from '../../utils/token'; + +const ERROR_MESSAGE_NO_QUOTES = 'No quotes found'; +const ERROR_MESSAGE_ALL_QUOTES_UNDER_MINIMUM = 'All quotes under minimum'; +const ATTEMPTS_MAX_DEFAULT = 5; +const BUFFER_INITIAL_DEFAULT = 0.04; +const BUFFER_STEP_DEFAULT = 0.04; +const BUFFER_SUBSEQUENT_DEFAULT = 0.05; +const SLIPPAGE_DEFAULT = 0.005; + +const log = createModuleLogger(projectLogger, 'bridge-strategy'); + +type BridgeQuoteRequest = QuoteRequest & { + attemptsMax: number; + bufferInitial: number; + bufferStep: number; + bufferSubsequent: number; + slippage: number; + sourceBalanceRaw: string; +}; + +/** + * Fetch bridge quotes for multiple requests. + * + * @param requests - An array of bridge quote requests. + * @param messenger - Controller messenger. + * @returns An array of bridge quotes. + */ +export async function getBridgeQuotes( + requests: QuoteRequest[], + messenger: TransactionPayControllerMessenger, +): Promise[]> { + log('Fetching quotes', requests); + + const finalRequests = getFinalRequests(requests, messenger); + + const quotes = await Promise.all( + finalRequests.map((request, index) => + getSufficientSingleBridgeQuote(request, index, messenger), + ), + ); + + return quotes.map((quote, index) => + normalizeQuote(quote, finalRequests[index], messenger), + ); +} + +/** + * Retry fetching a single bridge quote until it meets the minimum target amount. + * + * @param request - Original quote request. + * @param index - Index of the request in the array. + * @param messenger - Controller messenger. + * @returns The sufficient bridge quote. + */ +async function getSufficientSingleBridgeQuote( + request: BridgeQuoteRequest, + index: number, + messenger: TransactionPayControllerMessenger, +): Promise { + const { + attemptsMax, + bufferInitial, + bufferStep, + bufferSubsequent, + sourceBalanceRaw, + sourceTokenAmount, + targetTokenAddress, + } = request; + + const sourceAmountValue = new BigNumber(sourceTokenAmount); + const buffer = index === 0 ? bufferInitial : bufferSubsequent; + const originalSourceAmount = sourceAmountValue.div(1 + buffer); + + let currentSourceAmount = sourceTokenAmount; + + for (let i = 0; i < attemptsMax; i++) { + const currentRequest = { + ...request, + sourceTokenAmount: currentSourceAmount, + }; + + try { + log('Attempt', { + attempt: i + 1, + attemptsMax, + bufferInitial, + bufferStep, + currentSourceAmount, + target: targetTokenAddress, + }); + + const result = await getSingleBridgeQuote(currentRequest, messenger); + + const dust = new BigNumber(result.quote.minDestTokenAmount) + .minus(request.targetAmountMinimum) + .toString(10); + + log('Found valid quote', { + attempt: i + 1, + target: targetTokenAddress, + targetAmount: result.quote.minDestTokenAmount, + goalAmount: request.targetAmountMinimum, + dust, + quote: result, + }); + + return result; + } catch (error) { + const errorMessage = (error as { message: string }).message; + + if (errorMessage !== ERROR_MESSAGE_ALL_QUOTES_UNDER_MINIMUM) { + throw error; + } + } + + if ( + new BigNumber(currentSourceAmount).isGreaterThanOrEqualTo( + sourceBalanceRaw, + ) + ) { + log('Reached balance limit', targetTokenAddress); + break; + } + + const newSourceAmount = originalSourceAmount.multipliedBy( + 1 + buffer + bufferStep * (i + 1), + ); + + currentSourceAmount = newSourceAmount.isLessThan(sourceBalanceRaw) + ? newSourceAmount.toFixed(0) + : sourceBalanceRaw; + } + + log('All attempts failed', request.targetTokenAddress); + + throw new Error(ERROR_MESSAGE_ALL_QUOTES_UNDER_MINIMUM); +} + +/** + * Fetch a single bridge quote. + * + * @param request - Quote request parameters. + * @param messenger - Controller messenger. + * @returns The bridge quote. + */ +async function getSingleBridgeQuote( + request: BridgeQuoteRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { + from, + slippage, + sourceChainId, + sourceTokenAddress, + sourceTokenAmount, + targetChainId, + targetTokenAddress, + } = request; + + const quoteRequest: GenericQuoteRequest = { + destChainId: targetChainId, + destTokenAddress: toChecksumHexAddress(targetTokenAddress), + destWalletAddress: from, + gasIncluded: false, + gasIncluded7702: false, + insufficientBal: false, + slippage: slippage * 100, + srcChainId: sourceChainId, + srcTokenAddress: toChecksumHexAddress(sourceTokenAddress), + srcTokenAmount: sourceTokenAmount, + walletAddress: from, + }; + + const quotes = await messenger.call( + 'BridgeController:fetchQuotes', + quoteRequest, + ); + + if (!quotes.length) { + throw new Error(ERROR_MESSAGE_NO_QUOTES); + } + + return getBestQuote(quotes, request); +} + +/** + * Select the best quote from a list of quotes. + * + * @param quotes - List of quotes. + * @param request - Original quote request. + * @returns The best quote. + */ +function getBestQuote( + quotes: QuoteResponse[], + request: BridgeQuoteRequest, +): QuoteResponse { + const fastestQuotes = orderBy( + quotes, + (quote) => quote.estimatedProcessingTimeInSeconds, + 'asc', + ).slice(0, 3); + + const quotesOverMinimumTarget = fastestQuotes.filter((quote) => + new BigNumber(quote.quote.minDestTokenAmount).isGreaterThanOrEqualTo( + request.targetAmountMinimum, + ), + ); + + log('Finding best quote', { + allQuotes: quotes, + fastestQuotes, + quotesOverMinimumTarget, + }); + + if (!quotesOverMinimumTarget.length) { + throw new Error(ERROR_MESSAGE_ALL_QUOTES_UNDER_MINIMUM); + } + + const cheapestQuote = orderBy( + quotesOverMinimumTarget, + (quote) => BigNumber(quote.quote.minDestTokenAmount).toNumber(), + 'desc', + )[0]; + + return cheapestQuote; +} + +/** + * Get the final bridge quote requests. + * Subtracts subsequent source amounts from the available balance. + * + * @param requests - List of bridge quote requests. + * @param messenger - Controller messenger. + * @returns The final bridge quote requests. + */ +function getFinalRequests( + requests: QuoteRequest[], + messenger: TransactionPayControllerMessenger, +): BridgeQuoteRequest[] { + const featureFlags = getFeatureFlags(messenger); + + return requests + .map((request) => ({ ...request, ...featureFlags })) + .map((request, index) => { + const isFirstRequest = index === 0; + const attemptsMax = isFirstRequest ? request.attemptsMax : 1; + + const sourceBalanceRaw = requests + .reduce((acc, value, j) => { + const isSameSource = + value.sourceTokenAddress.toLowerCase() === + request.sourceTokenAddress.toLowerCase() && + value.sourceChainId === request.sourceChainId; + + if (isFirstRequest && j > index && isSameSource) { + return acc.minus(value.sourceTokenAmount); + } + + return acc; + }, new BigNumber(request.sourceBalanceRaw)) + .toFixed(0); + + return { + ...request, + attemptsMax, + sourceBalanceRaw, + }; + }); +} + +/** + * Get feature flags for bridge quotes. + * + * @param messenger - Controller messenger. + * @returns Feature flags. + */ +function getFeatureFlags(messenger: TransactionPayControllerMessenger) { + const featureFlags = messenger.call('RemoteFeatureFlagController:getState') + .remoteFeatureFlags.confirmation_pay as Record | undefined; + + return { + attemptsMax: featureFlags?.attemptsMax ?? ATTEMPTS_MAX_DEFAULT, + bufferInitial: featureFlags?.bufferInitial ?? BUFFER_INITIAL_DEFAULT, + bufferStep: featureFlags?.bufferStep ?? BUFFER_STEP_DEFAULT, + bufferSubsequent: + featureFlags?.bufferSubsequent ?? BUFFER_SUBSEQUENT_DEFAULT, + slippage: featureFlags?.slippage ?? SLIPPAGE_DEFAULT, + }; +} + +/** + * Convert a bridge specific quote response to a normalized transaction pay quote. + * + * @param quote - Bridge quote response. + * @param request - Request + * @param messenger - Controller messenger. + * @returns Normalized transaction pay quote. + */ +function normalizeQuote( + quote: QuoteResponse, + request: BridgeQuoteRequest, + messenger: TransactionPayControllerMessenger, +): TransactionPayQuote { + const targetFiatRate = getTokenFiatRate( + messenger, + quote.quote.destAsset.address as Hex, + toHex(quote.quote.destChainId), + ); + + const sourceFiatRate = getTokenFiatRate( + messenger, + quote.quote.srcAsset.address as Hex, + toHex(quote.quote.srcChainId), + ); + + if (sourceFiatRate === undefined || targetFiatRate === undefined) { + throw new Error('Fiat rate not found for source or target token'); + } + + const targetAmountMinimumFiat = calculateFiatValue( + quote.quote.minDestTokenAmount, + quote.quote.destAsset.decimals, + targetFiatRate.fiatRate, + targetFiatRate.usdRate, + ); + + const sourceAmountFiat = calculateFiatValue( + quote.quote.srcTokenAmount, + quote.quote.srcAsset.decimals, + sourceFiatRate.fiatRate, + sourceFiatRate.usdRate, + ); + + const targetAmountGoal = calculateFiatValue( + request.targetAmountMinimum, + quote.quote.destAsset.decimals, + targetFiatRate.fiatRate, + targetFiatRate.usdRate, + ); + + return { + estimatedDuration: quote.estimatedProcessingTimeInSeconds, + dust: { + fiat: new BigNumber(targetAmountMinimumFiat.fiat) + .minus(targetAmountGoal.fiat) + .toString(10), + usd: new BigNumber(targetAmountMinimumFiat.usd) + .minus(targetAmountGoal.usd) + .toString(10), + }, + fees: { + provider: { + fiat: new BigNumber(sourceAmountFiat.fiat) + .minus(targetAmountMinimumFiat.fiat) + .toString(10), + usd: new BigNumber(sourceAmountFiat.usd) + .minus(targetAmountMinimumFiat.usd) + .toString(10), + }, + sourceNetwork: { + fiat: '0', + usd: '0', + }, + targetNetwork: { + fiat: '0', + usd: '0', + }, + }, + original: quote, + request, + }; +} + +/** + * Calculate fiat value from amount and fiat rates. + * + * @param amount - Amount to convert. + * @param decimals - Token decimals. + * @param fiatRateFiat - Fiat rate. + * @param fiatRateUsd - USD rate. + * @returns Fiat value. + */ +function calculateFiatValue( + amount: string, + decimals: number, + fiatRateFiat: string, + fiatRateUsd: string, +): FiatValue { + const amountHuman = new BigNumber(amount).shiftedBy(-decimals); + const usd = amountHuman.multipliedBy(fiatRateUsd).toString(10); + const fiat = amountHuman.multipliedBy(fiatRateFiat).toString(10); + + return { fiat, usd }; +} diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts new file mode 100644 index 00000000000..86f5ef77955 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.test.ts @@ -0,0 +1,247 @@ +import { Messenger } from '@metamask/base-controller'; +import { StatusTypes } from '@metamask/bridge-controller'; +import type { QuoteResponse } from '@metamask/bridge-controller'; +import type { BridgeStatusController } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerEvents } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerState } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { TransactionControllerUnapprovedTransactionAddedEvent } from '@metamask/transaction-controller'; +import { cloneDeep, noop } from 'lodash'; + +import type { SubmitBridgeQuotesRequest } from './bridge-submit'; +import { submitBridgeQuotes } from './bridge-submit'; +import type { TransactionPayQuote } from '../../types'; + +const FROM_MOCK = '0x123'; +const CHAIN_ID_MOCK = toHex(123); +const BRIDGE_TRANSACTION_ID_MOCK = '456-789'; +const BRIDGE_TRANSACTION_ID_2_MOCK = '789-012'; + +const QUOTE_MOCK = { + original: { + quote: { + srcChainId: 123, + }, + trade: { gasLimit: 2000 }, + }, +} as TransactionPayQuote; + +const QUOTE_2_MOCK = { + ...QUOTE_MOCK, + original: { + ...QUOTE_MOCK.original, + approval: { gasLimit: 3000 }, + }, +} as TransactionPayQuote; + +const BRIDGE_TRANSACTION_META_MOCK = { + id: BRIDGE_TRANSACTION_ID_MOCK, +} as TransactionMeta; + +const BRIDGE_TRANSACTION_META_2_MOCK = { + id: BRIDGE_TRANSACTION_ID_2_MOCK, +} as TransactionMeta; + +describe('Bridge Submit Utils', () => { + let messengerMock: Messenger< + BridgeStatusControllerActions, + | BridgeStatusControllerEvents + | TransactionControllerUnapprovedTransactionAddedEvent + >; + + let request: SubmitBridgeQuotesRequest; + + const submitTransactionMock: jest.MockedFunction< + BridgeStatusController['submitTx'] + > = jest.fn(); + + /** + * Simulate the bridge status controller state change event. + * + * @param transactionId - The ID of the transaction to update. + * @param status - The new status to set. + */ + function updateBridgeStatus( + transactionId: string, + status: StatusTypes, + ): void { + messengerMock.publish( + 'BridgeStatusController:stateChange', + { + txHistory: { + [transactionId]: { + status: { + status, + }, + }, + }, + } as unknown as BridgeStatusControllerState, + [], + ); + } + + /** + * Simulate adding an unapproved transaction. + * + * @param id - The ID of the transaction to add. + */ + function addUnapprovedTransaction(id: string): void { + messengerMock.publish('TransactionController:unapprovedTransactionAdded', { + id, + chainId: CHAIN_ID_MOCK, + txParams: { + from: FROM_MOCK, + }, + } as unknown as TransactionMeta); + } + + beforeEach(() => { + jest.resetAllMocks(); + + messengerMock = new Messenger< + BridgeStatusControllerActions, + | BridgeStatusControllerStateChangeEvent + | TransactionControllerUnapprovedTransactionAddedEvent + >(); + + messengerMock.registerActionHandler( + 'BridgeStatusController:submitTx', + submitTransactionMock, + ); + + submitTransactionMock.mockImplementationOnce(async () => { + setTimeout(() => { + updateBridgeStatus(BRIDGE_TRANSACTION_ID_MOCK, StatusTypes.COMPLETE); + }, 0); + + addUnapprovedTransaction(BRIDGE_TRANSACTION_ID_MOCK); + + return BRIDGE_TRANSACTION_META_MOCK; + }); + + submitTransactionMock.mockImplementationOnce(async () => { + setTimeout(() => { + updateBridgeStatus(BRIDGE_TRANSACTION_ID_2_MOCK, StatusTypes.COMPLETE); + }, 0); + + addUnapprovedTransaction(BRIDGE_TRANSACTION_ID_2_MOCK); + + return BRIDGE_TRANSACTION_META_2_MOCK; + }); + + request = { + from: FROM_MOCK, + isSmartTransaction: () => false, + messenger: messengerMock, + quotes: cloneDeep([QUOTE_MOCK, QUOTE_2_MOCK]), + updateTransaction: noop, + }; + }); + + describe('submitBridgeQuotes', () => { + it('submits matching quotes to bridge status controller', async () => { + await submitBridgeQuotes(request); + + expect(submitTransactionMock).toHaveBeenCalledWith( + FROM_MOCK, + expect.objectContaining(QUOTE_MOCK.original), + false, + ); + + expect(submitTransactionMock).toHaveBeenCalledWith( + FROM_MOCK, + expect.objectContaining(QUOTE_2_MOCK.original), + false, + ); + }); + + it('indicates if smart transactions is enabled', async () => { + request.isSmartTransaction = () => true; + + await submitBridgeQuotes(request); + + expect(submitTransactionMock).toHaveBeenCalledWith( + FROM_MOCK, + expect.objectContaining(QUOTE_MOCK.original), + true, + ); + + expect(submitTransactionMock).toHaveBeenCalledWith( + FROM_MOCK, + expect.objectContaining(QUOTE_2_MOCK.original), + true, + ); + }); + + it('does nothing if no matching quotes', async () => { + request.quotes = []; + + await submitBridgeQuotes(request); + + expect(submitTransactionMock).not.toHaveBeenCalled(); + }); + + it('does nothing if first quote has same source and target chain', async () => { + request.quotes[0].original.quote.destChainId = + QUOTE_MOCK.original.quote.srcChainId; + + await submitBridgeQuotes(request); + + expect(submitTransactionMock).not.toHaveBeenCalled(); + }); + + it('throws if bridge status is failed', async () => { + submitTransactionMock.mockReset(); + submitTransactionMock.mockImplementation(async () => { + setTimeout(() => { + updateBridgeStatus(BRIDGE_TRANSACTION_ID_MOCK, StatusTypes.FAILED); + }, 0); + + return BRIDGE_TRANSACTION_META_MOCK; + }); + + await expect(submitBridgeQuotes(request)).rejects.toThrow( + 'Bridge transaction failed', + ); + }); + + it('updates required transaction IDs', async () => { + const updateTransactionMock = jest.fn(); + request.updateTransaction = updateTransactionMock; + + await submitBridgeQuotes(request); + + const transactionMetaMock = {} as TransactionMeta; + updateTransactionMock.mock.calls[0][0](transactionMetaMock); + updateTransactionMock.mock.calls[1][0](transactionMetaMock); + + expect(transactionMetaMock.requiredTransactionIds).toStrictEqual([ + BRIDGE_TRANSACTION_ID_MOCK, + BRIDGE_TRANSACTION_ID_2_MOCK, + ]); + }); + + it('does not update required transaction IDs if chain ID does not match', async () => { + const updateTransactionMock = jest.fn(); + request.updateTransaction = updateTransactionMock; + request.quotes[0].original.quote.srcChainId = 321; + + await submitBridgeQuotes(request); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + }); + + it('does not update required transaction IDs if from does not match', async () => { + const updateTransactionMock = jest.fn(); + request.updateTransaction = updateTransactionMock; + request.from = '0x456'; + + await submitBridgeQuotes(request); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts new file mode 100644 index 00000000000..bd4459fbab3 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/bridge/bridge-submit.ts @@ -0,0 +1,213 @@ +import { StatusTypes } from '@metamask/bridge-controller'; +import type { QuoteMetadata } from '@metamask/bridge-controller'; +import type { QuoteResponse } from '@metamask/bridge-controller'; +import type { BridgeHistoryItem } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerState } from '@metamask/bridge-status-controller'; +import { toHex } from '@metamask/controller-utils'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { TransactionPayPublishHookMessenger } from '../..'; +import { projectLogger } from '../../logger'; +import type { TransactionPayQuote } from '../../types'; + +const log = createModuleLogger(projectLogger, 'bridge-strategy'); + +export type SubmitBridgeQuotesRequest = { + from: Hex; + isSmartTransaction: (chainId: Hex) => boolean; + messenger: TransactionPayPublishHookMessenger; + quotes: TransactionPayQuote[]; + updateTransaction: (fn: (transactionMeta: TransactionMeta) => void) => void; +}; + +/** + * Submit multiple bridge quotes sequentially and wait for their completion. + * + * @param request - The request object containing necessary data. + * @returns An object containing the transaction hash if available. + */ +export async function submitBridgeQuotes( + request: SubmitBridgeQuotesRequest, +): Promise { + const { quotes } = request; + + if (!quotes?.length) { + log('No quotes found'); + return; + } + + // Currently we only support a single source meaning we only check the first quote. + const isSameChain = + quotes[0].original.quote.srcChainId === + quotes[0].original.quote.destChainId; + + if (isSameChain) { + log( + 'Ignoring quotes as source is same chain', + quotes[0].original.quote.srcChainId, + ); + return; + } + + let index = 0; + + for (const quote of quotes) { + log('Submitting bridge', index, quote); + + await submitBridgeTransaction(request, quote.original); + + index += 1; + } +} + +/** + * Submit a bridge transaction and wait for it to complete. + * + * @param request - The request object containing necessary data. + * @param originalQuote - The original quote to submit. + */ +async function submitBridgeTransaction( + request: SubmitBridgeQuotesRequest, + originalQuote: QuoteResponse, +): Promise { + const { isSmartTransaction, messenger, from, updateTransaction } = request; + const quote = cloneDeep(originalQuote); + const sourceChainId = toHex(quote.quote.srcChainId); + const isSTX = isSmartTransaction(sourceChainId); + + const bridgeTransactionIdCollector = collectTransactionIds( + sourceChainId, + from, + messenger, + (id) => + updateTransaction((transactionMeta) => { + if (!transactionMeta.requiredTransactionIds) { + transactionMeta.requiredTransactionIds = []; + } + + transactionMeta?.requiredTransactionIds.push(id); + }), + ); + + const tokenAmountValues = { + amount: '0', + valueInCurrency: null, + usd: null, + }; + + const metadata: QuoteMetadata = { + gasFee: { + effective: tokenAmountValues, + max: tokenAmountValues, + total: tokenAmountValues, + }, + totalNetworkFee: tokenAmountValues, + totalMaxNetworkFee: tokenAmountValues, + toTokenAmount: tokenAmountValues, + minToTokenAmount: tokenAmountValues, + adjustedReturn: tokenAmountValues, + sentAmount: tokenAmountValues, + swapRate: '0', + cost: tokenAmountValues, + }; + + const result = await messenger.call( + 'BridgeStatusController:submitTx', + from, + { ...quote, ...metadata }, + isSTX, + ); + + bridgeTransactionIdCollector.end(); + + log('Bridge transaction submitted', result); + + const { id: bridgeTransactionId } = result; + + log('Waiting for bridge completion', bridgeTransactionId); + + await waitForBridgeCompletion(bridgeTransactionId, messenger); +} + +/** + * Wait for a bridge transaction to complete. + * + * @param bridgeTransactionId - The bridge transaction ID. + * @param messenger - The controller messenger. + */ +async function waitForBridgeCompletion( + bridgeTransactionId: string, + messenger: TransactionPayPublishHookMessenger, +): Promise { + return new Promise((resolve, reject) => { + const handler = (bridgeHistory: BridgeHistoryItem) => { + const unsubscribe = () => + messenger.unsubscribe('BridgeStatusController:stateChange', handler); + + const status = bridgeHistory?.status?.status; + + log('Checking bridge status', status); + + if (status === StatusTypes.COMPLETE) { + unsubscribe(); + resolve(); + } + + if (status === StatusTypes.FAILED) { + unsubscribe(); + reject(new Error('Bridge transaction failed')); + } + }; + + messenger.subscribe( + 'BridgeStatusController:stateChange', + handler, + (state: BridgeStatusControllerState) => + state.txHistory[bridgeTransactionId], + ); + }); +} + +/** + * Collect all new transactions until `end` is called. + * + * @param chainId - The chain ID to filter transactions by. + * @param from - The address to filter transactions by. + * @param messenger - The controller messenger. + * @param onTransaction - Callback called with each matching transaction ID. + * @returns An object with an `end` method to stop collecting transactions. + */ +function collectTransactionIds( + chainId: Hex, + from: Hex, + messenger: TransactionPayPublishHookMessenger, + onTransaction: (transactionId: string) => void, +): { end: () => void } { + const listener = (tx: TransactionMeta) => { + if ( + tx.chainId !== chainId || + tx.txParams.from.toLowerCase() !== from.toLowerCase() + ) { + return; + } + + onTransaction(tx.id); + }; + + messenger.subscribe( + 'TransactionController:unapprovedTransactionAdded', + listener, + ); + + const end = () => { + messenger.unsubscribe( + 'TransactionController:unapprovedTransactionAdded', + listener, + ); + }; + + return { end }; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayBridge.test.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayBridge.test.ts new file mode 100644 index 00000000000..8a15b5630cf --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayBridge.test.ts @@ -0,0 +1,55 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { getRelayQuotes } from './relay-quotes'; +import { submitRelayQuotes } from './relay-submit'; +import { RelayStrategy } from './RelayStrategy'; +import type { RelayQuote } from './types'; +import type { + TransactionPayControllerMessenger, + TransactionPayPublishHookMessenger, +} from '../..'; +import type { TransactionPayQuote } from '../../types'; + +jest.mock('./relay-quotes'); +jest.mock('./relay-submit'); + +const QUOTE_MOCK = { + estimatedDuration: 5, +} as TransactionPayQuote; + +describe('RelayStrategy', () => { + const getBridgeQuotesMock = jest.mocked(getRelayQuotes); + const submitRelayQuotesMock = jest.mocked(submitRelayQuotes); + + beforeEach(() => { + jest.resetAllMocks(); + getBridgeQuotesMock.mockResolvedValue([QUOTE_MOCK]); + }); + + describe('getQuotes', () => { + it('returns result from util', async () => { + const result = new RelayStrategy().getQuotes({ + messenger: {} as TransactionPayControllerMessenger, + requests: [], + }); + + expect(await result).toStrictEqual([QUOTE_MOCK]); + }); + }); + + describe('execute', () => { + it('calls util', async () => { + await new RelayStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayPublishHookMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(submitRelayQuotesMock).toHaveBeenCalledTimes(1); + expect( + submitRelayQuotesMock.mock.calls[0][0].transaction.txParams.from, + ).toBe('0x1'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts new file mode 100644 index 00000000000..f59346553cd --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/RelayStrategy.ts @@ -0,0 +1,18 @@ +import { getRelayQuotes } from './relay-quotes'; +import { submitRelayQuotes } from './relay-submit'; +import type { RelayQuote } from './types'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, +} from '../../types'; + +export class RelayStrategy implements PayStrategy { + async getQuotes(request: PayStrategyGetQuotesRequest) { + return getRelayQuotes(request); + } + + async execute(request: PayStrategyExecuteRequest) { + return await submitRelayQuotes(request); + } +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/constants.ts b/packages/transaction-pay-controller/src/strategy/relay/constants.ts new file mode 100644 index 00000000000..1cd7a530018 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/constants.ts @@ -0,0 +1,5 @@ +export const ARBITRUM_USDC_ADDRESS = + '0xaf88d065e77c8cC2239327C5EDb3A432268e5831'; +export const CHAIN_ID_ARBITRUM = '0xa4b1'; +export const RELAY_URL_BASE = 'https://api.relay.link'; +export const RELAY_URL_QUOTE = `${RELAY_URL_BASE}/quote`; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts new file mode 100644 index 00000000000..e8ea4aa6b39 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -0,0 +1,179 @@ +import { successfulFetch } from '@metamask/controller-utils'; + +import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, + RELAY_URL_QUOTE, +} from './constants'; +import { getRelayQuotes } from './relay-quotes'; +import type { RelayQuote } from './types'; +import type { QuoteRequest } from '../../types'; + +jest.mock('@metamask/controller-utils', () => ({ + ...jest.requireActual('@metamask/controller-utils'), + successfulFetch: jest.fn(), +})); + +const QUOTE_REQUEST_MOCK: QuoteRequest = { + from: '0x123', + sourceBalanceRaw: '10000000000000000000', + sourceChainId: '0x1', + sourceTokenAddress: '0xabc', + sourceTokenAmount: '1000000000000000000', + targetAmountMinimum: '123', + targetChainId: '0x2', + targetTokenAddress: '0xdef', +}; + +const QUOTE_MOCK: RelayQuote = { + details: { + currencyIn: { + amountUsd: '2.34', + }, + currencyOut: { + amountUsd: '1.23', + }, + timeEstimate: 300, + }, + steps: [ + { + items: [ + { + check: { + endpoint: '/test', + method: 'GET', + }, + data: { + chainId: 1, + data: '0x123', + from: '0x1', + gas: '21000', + maxFeePerGas: '1000000000', + maxPriorityFeePerGas: '2000000000', + to: '0x2', + value: '300000', + }, + status: 'complete', + }, + ], + kind: 'transaction', + }, + ], +}; + +describe('Relay Quotes Utils', () => { + const successfulFetchMock = jest.mocked(successfulFetch); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('getRelayQuotes', () => { + it('returns quotes from Relay', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger: {} as never, + requests: [QUOTE_REQUEST_MOCK], + }); + + expect(result).toStrictEqual([ + expect.objectContaining({ + original: QUOTE_MOCK, + }), + ]); + }); + + it('sends request to Relay', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger: {} as never, + requests: [QUOTE_REQUEST_MOCK], + }); + + expect(successfulFetchMock).toHaveBeenCalledWith( + RELAY_URL_QUOTE, + expect.objectContaining({ + body: JSON.stringify({ + amount: QUOTE_REQUEST_MOCK.targetAmountMinimum, + destinationChainId: 2, + destinationCurrency: QUOTE_REQUEST_MOCK.targetTokenAddress, + originChainId: 1, + originCurrency: QUOTE_REQUEST_MOCK.sourceTokenAddress, + recipient: QUOTE_REQUEST_MOCK.from, + tradeType: 'EXPECTED_OUTPUT', + user: QUOTE_REQUEST_MOCK.from, + }), + }), + ); + }); + + it('normalizes quotes', async () => { + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + const result = await getRelayQuotes({ + messenger: {} as never, + requests: [QUOTE_REQUEST_MOCK], + }); + + expect(result[0]).toStrictEqual( + expect.objectContaining({ + estimatedDuration: 300, + fees: expect.objectContaining({ + provider: { + usd: '1.11', + fiat: '1.11', + }, + }), + }), + ); + }); + + it('throws if fetching quote fails', async () => { + successfulFetchMock.mockRejectedValue(new Error('Fetch error')); + + await expect( + getRelayQuotes({ + messenger: {} as never, + requests: [QUOTE_REQUEST_MOCK], + }), + ).rejects.toThrow('Fetch error'); + }); + + it('updates request if Arbitrum deposit to Hyperliquid', async () => { + const arbitrumToHyperliquidRequest: QuoteRequest = { + ...QUOTE_REQUEST_MOCK, + targetChainId: CHAIN_ID_ARBITRUM, + targetTokenAddress: ARBITRUM_USDC_ADDRESS, + }; + + successfulFetchMock.mockResolvedValue({ + json: async () => QUOTE_MOCK, + } as never); + + await getRelayQuotes({ + messenger: {} as never, + requests: [arbitrumToHyperliquidRequest], + }); + + const body = JSON.parse( + successfulFetchMock.mock.calls[0][1]?.body as string, + ); + + expect(body).toStrictEqual( + expect.objectContaining({ + amount: '12300', + destinationChainId: 1337, + destinationCurrency: '0x00000000000000000000000000000000', + }), + ); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts new file mode 100644 index 00000000000..f7ba48aef9d --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -0,0 +1,154 @@ +import { successfulFetch, toHex } from '@metamask/controller-utils'; +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { + ARBITRUM_USDC_ADDRESS, + CHAIN_ID_ARBITRUM, + RELAY_URL_QUOTE, +} from './constants'; +import type { RelayQuote } from './types'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyGetQuotesRequest, + QuoteRequest, + TransactionPayQuote, +} from '../../types'; + +const log = createModuleLogger(projectLogger, 'relay-strategy'); + +/** + * Fetches Relay quotes. + * + * @param request - Request object. + * @returns Array of quotes. + */ +export async function getRelayQuotes( + request: PayStrategyGetQuotesRequest, +): Promise[]> { + const { requests } = request; + + log('Fetching quotes', requests); + + const result = requests.map((r) => normalizeRequest(r)); + + const normalizedRequests = result.map((r) => r.request); + const isSkipTransaction = result.some((r) => r.isSkipTransaction); + + log('Normalized requests', { normalizedRequests, isSkipTransaction }); + + return await Promise.all( + normalizedRequests.map((r) => getSingleQuote(r, isSkipTransaction)), + ); +} + +/** + * Fetches a single Relay quote. + * + * @param request - Quote request. + * @param isSkipTransaction - Whether to skip the transaction. + * @returns Single quote. + */ +async function getSingleQuote( + request: QuoteRequest, + isSkipTransaction: boolean, +): Promise> { + try { + const body = { + amount: request.targetAmountMinimum, + destinationChainId: Number(request.targetChainId), + destinationCurrency: request.targetTokenAddress, + originChainId: Number(request.sourceChainId), + originCurrency: request.sourceTokenAddress, + recipient: request.from, + tradeType: 'EXPECTED_OUTPUT', + user: request.from, + }; + + const response = await successfulFetch(RELAY_URL_QUOTE, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const quote = (await response.json()) as RelayQuote; + quote.skipTransaction = isSkipTransaction; + + log('Fetched relay quote', quote); + + return normalizeQuote(quote, request); + } catch (e) { + log('Error fetching relay quote', e); + throw e; + } +} + +/** + * Normalizes requests for Relay. + * + * @param request - Quote request to normalize. + * @returns Normalized request. + */ +function normalizeRequest(request: QuoteRequest) { + const isHyperliquidDeposit = + request.targetChainId === CHAIN_ID_ARBITRUM && + request.targetTokenAddress.toLowerCase() === + ARBITRUM_USDC_ADDRESS.toLowerCase(); + + const requestOutput = { + ...request, + targetChainId: isHyperliquidDeposit ? toHex(1337) : request.targetChainId, + targetTokenAddress: isHyperliquidDeposit + ? '0x00000000000000000000000000000000' + : request.targetTokenAddress, + targetAmountMinimum: isHyperliquidDeposit + ? new BigNumber(request.targetAmountMinimum).shiftedBy(2).toString(10) + : request.targetAmountMinimum, + }; + + return { + request: requestOutput, + isSkipTransaction: isHyperliquidDeposit, + }; +} + +/** + * Normalizes a Relay quote into a TransactionPayQuote. + * + * @param quote - Relay quote. + * @param request - Original quote request. + * @returns Normalized quote. + */ +function normalizeQuote( + quote: RelayQuote, + request: QuoteRequest, +): TransactionPayQuote { + const feeUsd = new BigNumber(quote.details?.currencyIn?.amountUsd ?? '0') + .minus(quote?.details?.currencyOut?.amountUsd ?? '0') + .toString(10); + + const sourceNetworkFeeUsd = new BigNumber( + quote.fees?.gas?.amountUsd ?? '0', + ).toString(10); + + return { + dust: { + usd: '0', + fiat: '0', + }, + estimatedDuration: quote.details?.timeEstimate ?? 0, + fees: { + provider: { + usd: feeUsd, + fiat: feeUsd, + }, + sourceNetwork: { + usd: sourceNetworkFeeUsd, + fiat: sourceNetworkFeeUsd, + }, + targetNetwork: { usd: '0', fiat: '0' }, + }, + original: quote, + request, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts new file mode 100644 index 00000000000..709f7c766f7 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -0,0 +1,169 @@ +import { + ORIGIN_METAMASK, + successfulFetch, + toHex, +} from '@metamask/controller-utils'; +import type { TransactionParams } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { RELAY_URL_BASE } from './constants'; +import type { RelayQuote, RelayStatus } from './types'; +import { projectLogger } from '../../logger'; +import type { + PayStrategyExecuteRequest, + TransactionPayPublishHookMessenger, +} from '../../types'; +import { + updateTransaction, + waitForTransactionConfirmed, +} from '../../utils/transaction'; + +const log = createModuleLogger(projectLogger, 'relay-strategy'); + +/** + * Submits Relay quotes. + * + * @param request - Request object. + * @returns An object containing the transaction hash if available. + */ +export async function submitRelayQuotes( + request: PayStrategyExecuteRequest, +): Promise<{ transactionHash?: Hex }> { + log('Executing quotes', request); + + const { quotes, messenger } = request; + + let transactionHash: Hex | undefined; + + for (const quote of quotes) { + ({ transactionHash } = await executeSingleQuote(quote.original, messenger)); + } + + const isSkipTransaction = quotes.some((q) => q.original.skipTransaction); + + if (isSkipTransaction) { + return { transactionHash }; + } + + return { transactionHash: undefined }; +} + +/** + * Executes a single Relay quote. + * + * @param quote - Relay quote to execute. + * @param messenger - Controller messenger. + * @returns An object containing the transaction hash if available. + */ +async function executeSingleQuote( + quote: RelayQuote, + messenger: TransactionPayPublishHookMessenger, +) { + log('Executing single quote', quote); + + const transactionParams = quote.steps[0].items[0].data; + const chainId = toHex(transactionParams.chainId); + const normalizedParams = normalizeParams(transactionParams); + + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + log('Adding transaction', { + chainId, + normalizedParams, + networkClientId, + }); + + const result = await messenger.call( + 'TransactionController:addTransaction', + normalizedParams, + { + networkClientId, + origin: ORIGIN_METAMASK, + requireApproval: false, + }, + ); + + const { transactionMeta, result: transactionHashPromise } = result; + + log('Added transaction', transactionMeta); + + const transactionHash = (await transactionHashPromise) as Hex; + + log('Submitted transaction', transactionHash); + + await waitForTransactionConfirmed(transactionMeta.id, messenger); + + log('Transaction confirmed', transactionMeta.id); + + await waitForRelayCompletion(quote); + + log('Relay request completed'); + + if (quote.skipTransaction) { + log('Updating intent complete flag on transaction', transactionMeta.id); + + updateTransaction( + { + transactionId: transactionMeta.id, + messenger, + note: 'Intent complete after Relay completion', + }, + (tx) => { + tx.isIntentComplete = true; + }, + ); + } + + return { transactionHash }; +} + +/** + * Wait for a Relay request to complete. + * + * @param quote - Relay quote associated with the request. + * @returns A promise that resolves when the Relay request is complete. + */ +async function waitForRelayCompletion(quote: RelayQuote) { + const url = `${RELAY_URL_BASE}${quote.steps[0].items[0].check.endpoint}`; + + while (true) { + const response = await successfulFetch(url); + const status = (await response.json()) as RelayStatus; + + log('Polled status', status.status, status); + + if (status.status === 'success') { + return; + } + + if (['failure', 'refund'].includes(status.status)) { + throw new Error(`Relay request failed with status: ${status.status}`); + } + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } +} + +/** + * Normalize the parameters from a relay quote step to match TransactionParams. + * + * @param params - Parameters from a relay quote step. + * @returns Normalized transaction parameters. + */ +function normalizeParams( + params: RelayQuote['steps'][0]['items'][0]['data'], +): TransactionParams { + return { + data: params.data, + from: params.from, + gas: toHex(params.gas), + maxFeePerGas: toHex(params.maxFeePerGas), + maxPriorityFeePerGas: toHex(params.maxPriorityFeePerGas), + to: params.to, + value: params.value, + }; +} diff --git a/packages/transaction-pay-controller/src/strategy/relay/types.ts b/packages/transaction-pay-controller/src/strategy/relay/types.ts new file mode 100644 index 00000000000..7095f64c2de --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/relay/types.ts @@ -0,0 +1,54 @@ +import type { Hex } from '@metamask/utils'; + +export type RelayQuote = { + details?: { + currencyIn?: { + amountUsd?: string; + }; + currencyOut?: { + amountUsd?: string; + }; + timeEstimate?: number; + }; + fees?: { + gas?: { + amountUsd?: string; + }; + }; + steps: { + items: { + check: { + endpoint: string; + method: 'GET' | 'POST'; + }; + data: { + chainId: number; + data: Hex; + from: Hex; + gas: string; + maxFeePerGas: string; + maxPriorityFeePerGas: string; + to: Hex; + value: string; + }; + status: 'complete' | 'incomplete'; + }[]; + kind: 'transaction'; + }[]; + skipTransaction?: boolean; +}; + +export type RelayStatus = { + status: + | 'refund' + | 'waiting' + | 'failure' + | 'pending' + | 'submitted' + | 'success'; + inTxHashes: string[]; + txHashes: string[]; + updatedAt: number; + originChainId: number; + destinationChainId: number; +}; diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts new file mode 100644 index 00000000000..0dd7e89e322 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.test.ts @@ -0,0 +1,72 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { TestStrategy } from './TestStrategy'; +import type { TransactionPayPublishHookMessenger } from '../..'; +import type { + QuoteRequest, + TransactionPayControllerMessenger, + TransactionPayQuote, +} from '../../types'; + +jest.useFakeTimers(); + +const REQUEST_MOCK = {} as QuoteRequest; +const QUOTE_MOCK = {} as TransactionPayQuote; + +describe('TestStrategy', () => { + describe('getQuotes', () => { + it('returns quote', async () => { + const quotesPromise = new TestStrategy().getQuotes({ + messenger: {} as TransactionPayControllerMessenger, + requests: [REQUEST_MOCK], + }); + + jest.runAllTimers(); + + const quotes = await quotesPromise; + + expect(quotes).toStrictEqual([ + { + dust: { + fiat: expect.any(String), + usd: expect.any(String), + }, + estimatedDuration: expect.any(Number), + fees: { + provider: { + fiat: expect.any(String), + usd: expect.any(String), + }, + sourceNetwork: { + fiat: expect.any(String), + usd: expect.any(String), + }, + targetNetwork: { + fiat: expect.any(String), + usd: expect.any(String), + }, + }, + original: undefined, + request: REQUEST_MOCK, + }, + ]); + }); + }); + + describe('execute', () => { + it('resolves', async () => { + const executePromise = new TestStrategy().execute({ + isSmartTransaction: () => false, + messenger: {} as TransactionPayPublishHookMessenger, + quotes: [QUOTE_MOCK], + transaction: {} as TransactionMeta, + }); + + jest.runAllTimers(); + + expect(await executePromise).toStrictEqual({ + transactionHash: undefined, + }); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts new file mode 100644 index 00000000000..9b1a51aaed4 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/test/TestStrategy.ts @@ -0,0 +1,51 @@ +import { createModuleLogger } from '@metamask/utils'; + +import { projectLogger } from '../../logger'; +import type { + PayStrategy, + PayStrategyExecuteRequest, + PayStrategyGetQuotesRequest, + TransactionPayQuote, +} from '../../types'; + +const log = createModuleLogger(projectLogger, 'test-strategy'); + +export class TestStrategy implements PayStrategy { + async getQuotes( + request: PayStrategyGetQuotesRequest, + ): Promise[]> { + const { requests } = request; + + log('Getting quotes', requests); + + await this.#timeout(5000); + + return [ + { + dust: { fiat: '0.12', usd: '0.34' }, + estimatedDuration: 5, + fees: { + provider: { fiat: '1.23', usd: '1.23' }, + sourceNetwork: { fiat: '2.34', usd: '2.34' }, + targetNetwork: { fiat: '3.45', usd: '3.45' }, + }, + original: undefined, + request: requests[0], + }, + ]; + } + + async execute(request: PayStrategyExecuteRequest) { + const { quotes } = request; + + log('Executing', quotes); + + await this.#timeout(5000); + + return { transactionHash: undefined }; + } + + #timeout(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts new file mode 100644 index 00000000000..c459a8adcc7 --- /dev/null +++ b/packages/transaction-pay-controller/src/types.ts @@ -0,0 +1,218 @@ +import type { + CurrencyRateControllerActions, + TokenBalancesControllerGetStateAction, +} from '@metamask/assets-controllers'; +import type { TokenListControllerActions } from '@metamask/assets-controllers'; +import type { TokenRatesControllerGetStateAction } from '@metamask/assets-controllers'; +import type { TokensControllerGetStateAction } from '@metamask/assets-controllers'; +import type { RestrictedMessenger } from '@metamask/base-controller'; +import type { ControllerStateChangeEvent } from '@metamask/base-controller'; +import type { ControllerGetStateAction } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/base-controller'; +import type { BridgeControllerActions } from '@metamask/bridge-controller'; +import type { BridgeStatusControllerStateChangeEvent } from '@metamask/bridge-status-controller'; +import type { BridgeStatusControllerActions } from '@metamask/bridge-status-controller'; +import type { NetworkControllerFindNetworkClientIdByChainIdAction } from '@metamask/network-controller'; +import type { NetworkControllerGetNetworkClientByIdAction } from '@metamask/network-controller'; +import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; +import type { TransactionControllerUnapprovedTransactionAddedEvent } from '@metamask/transaction-controller'; +import type { TransactionControllerGetStateAction } from '@metamask/transaction-controller'; +import type { TransactionControllerStateChangeEvent } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { TransactionControllerAddTransactionAction } from '@metamask/transaction-controller'; +import type { TransactionControllerUpdateTransactionAction } from '@metamask/transaction-controller'; +import type { Hex, Json } from '@metamask/utils'; +import type { Draft } from 'immer'; + +import type { CONTROLLER_NAME, TransactionPayStrategy } from './constants'; + +export type AllowedActions = + | BridgeControllerActions + | BridgeStatusControllerActions + | CurrencyRateControllerActions + | NetworkControllerFindNetworkClientIdByChainIdAction + | NetworkControllerGetNetworkClientByIdAction + | RemoteFeatureFlagControllerGetStateAction + | TokenBalancesControllerGetStateAction + | TokenListControllerActions + | TokenRatesControllerGetStateAction + | TokensControllerGetStateAction + | TransactionControllerAddTransactionAction + | TransactionControllerGetStateAction + | TransactionControllerUpdateTransactionAction; + +export type AllowedEvents = + | BridgeStatusControllerStateChangeEvent + | TransactionControllerStateChangeEvent + | TransactionControllerUnapprovedTransactionAddedEvent; + +export type TransactionPayControllerGetStateAction = ControllerGetStateAction< + typeof CONTROLLER_NAME, + TransactionPayControllerState +>; + +export type TransactionPayControllerGetStrategyAction = { + type: `${typeof CONTROLLER_NAME}:getStrategy`; + handler: (transaction: TransactionMeta) => Promise; +}; + +export type TransactionPayControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof CONTROLLER_NAME, + TransactionPayControllerState + >; + +export type TransactionPayControllerActions = + | TransactionPayControllerGetStateAction + | TransactionPayControllerGetStrategyAction; + +export type TransactionPayControllerEvents = + TransactionPayControllerStateChangeEvent; + +export type TransactionPayControllerMessenger = RestrictedMessenger< + typeof CONTROLLER_NAME, + TransactionPayControllerActions | AllowedActions, + TransactionPayControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] +>; + +export type TransactionPayPublishHookMessenger = Messenger< + | BridgeStatusControllerActions + | NetworkControllerFindNetworkClientIdByChainIdAction + | TransactionControllerAddTransactionAction + | TransactionControllerUpdateTransactionAction + | TransactionPayControllerGetStateAction + | TransactionPayControllerGetStrategyAction, + | BridgeStatusControllerStateChangeEvent + | TransactionControllerStateChangeEvent + | TransactionControllerUnapprovedTransactionAddedEvent +>; + +export type TransactionPayControllerOptions = { + getStrategy?: ( + transaction: TransactionMeta, + ) => Promise; + messenger: TransactionPayControllerMessenger; + state?: Partial; +}; + +export type TransactionPayControllerState = { + transactionData: Record; +}; + +export type TransactionData = { + isLoading: boolean; + paymentToken?: TransactionPaymentToken; + quotes?: TransactionPayQuote[]; + sourceAmounts?: SourceAmountValues[]; + tokens: TransactionToken[]; + totals?: TransactionPayTotals; +}; + +export type TransactionTokenRequired = { + address: Hex; + allowUnderMinimum: boolean; + amountHuman: string; + amountRaw: string; + balanceHuman: string; + balanceRaw: string; + chainId: Hex; + decimals: number; + skipIfBalance: boolean; + symbol: string; +}; + +export type TransactionTokenFiat = { + amountFiat: string; + amountUsd: string; + balanceFiat: string; + balanceUsd: string; +}; + +export type SourceAmountValues = { + sourceAmountHuman: string; + sourceAmountRaw: string; + targetTokenAddress: Hex; +}; + +export type TransactionToken = TransactionTokenRequired & TransactionTokenFiat; + +export type TransactionPaymentToken = { + address: Hex; + balanceFiat: string; + balanceHuman: string; + balanceRaw: string; + balanceUsd: string; + chainId: Hex; + decimals: number; + symbol: string; +}; + +export type UpdateTransactionDataCallback = ( + transactionId: string, + fn: (data: Draft) => void, +) => void; + +export type FiatRates = { + fiatRate: string; + usdRate: string; +}; + +export type QuoteRequest = { + from: Hex; + sourceBalanceRaw: string; + sourceChainId: Hex; + sourceTokenAddress: Hex; + sourceTokenAmount: string; + targetAmountMinimum: string; + targetChainId: Hex; + targetTokenAddress: Hex; +}; + +export type TransactionPayFees = { + provider: FiatValue; + sourceNetwork: FiatValue; + targetNetwork: FiatValue; +}; + +export type TransactionPayQuote = { + dust: FiatValue; + estimatedDuration: number; + fees: TransactionPayFees; + original: OriginalQuote; + request: QuoteRequest; +}; + +export type PayStrategyGetQuotesRequest = { + messenger: TransactionPayControllerMessenger; + requests: QuoteRequest[]; +}; + +export type PayStrategyExecuteRequest = { + isSmartTransaction: (chainId: Hex) => boolean; + messenger: TransactionPayPublishHookMessenger; + quotes: TransactionPayQuote[]; + transaction: TransactionMeta; +}; + +export type PayStrategy = { + getQuotes: ( + request: PayStrategyGetQuotesRequest, + ) => Promise[]>; + + execute: (request: PayStrategyExecuteRequest) => Promise<{ + transactionHash?: Hex; + }>; +}; + +export type FiatValue = { + fiat: string; + usd: string; +}; + +export type TransactionPayTotals = { + estimatedDuration: number; + fees: TransactionPayFees; + total: FiatValue; +}; diff --git a/packages/transaction-pay-controller/src/utils/quotes.test.ts b/packages/transaction-pay-controller/src/utils/quotes.test.ts new file mode 100644 index 00000000000..92fa14754a8 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quotes.test.ts @@ -0,0 +1,203 @@ +import { Messenger } from '@metamask/base-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import type { UpdateQuotesRequest } from './quotes'; +import { updateQuotes } from './quotes'; +import { getStrategy } from './strategy'; +import { calculateTotals } from './totals'; +import { getTransaction } from './transaction'; +import type { TransactionPayControllerMessenger } from '..'; +import type { + SourceAmountValues, + TransactionData, + TransactionPayQuote, + TransactionPayTotals, + TransactionPaymentToken, + TransactionToken, +} from '../types'; + +jest.mock('./strategy'); +jest.mock('./transaction'); +jest.mock('./totals'); + +const TRANSACTION_ID_MOCK = '123-456'; + +const TRANSACTION_DATA_MOCK: TransactionData = { + isLoading: false, + paymentToken: { + address: '0x123' as Hex, + balanceFiat: '123.45', + balanceHuman: '1.23', + balanceRaw: '2000000000000000000', + balanceUsd: '234.56', + chainId: '0x123', + decimals: 6, + symbol: 'ETH', + } as TransactionPaymentToken, + sourceAmounts: [ + { + sourceAmountRaw: '1000000000000000000', + } as SourceAmountValues, + ], + tokens: [{} as TransactionToken], +}; + +const TRANSACTION_META_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { from: '0xabc' as Hex }, +} as TransactionMeta; + +const QUOTE_MOCK = { + dust: { + usd: '1.23', + fiat: '2.34', + }, +} as TransactionPayQuote; + +const TOTALS_MOCK = { + total: { + fiat: '1.23', + usd: '4.56', + }, +} as TransactionPayTotals; + +describe('Quotes Utils', () => { + let messenger: TransactionPayControllerMessenger; + const updateTransactionDataMock = jest.fn(); + const getStrategyMock = jest.mocked(getStrategy); + const getTransactionMock = jest.mocked(getTransaction); + const calculateTotalsMock = jest.mocked(calculateTotals); + const getQuotesMock = jest.fn(); + + /** + * Run the updateQuotes function. + * + * @param params - Partial params to override the defaults. + */ + async function run(params?: Partial) { + await updateQuotes({ + messenger, + transactionData: cloneDeep(TRANSACTION_DATA_MOCK), + transactionId: TRANSACTION_ID_MOCK, + updateTransactionData: updateTransactionDataMock, + ...params, + }); + } + + beforeEach(() => { + jest.resetAllMocks(); + + messenger = new Messenger() as never; + + getStrategyMock.mockResolvedValue({ + execute: jest.fn(), + getQuotes: getQuotesMock, + }); + + getTransactionMock.mockReturnValue(TRANSACTION_META_MOCK); + getQuotesMock.mockResolvedValue([QUOTE_MOCK]); + }); + + describe('updateQuotes', () => { + it('updates quotes in state', async () => { + await run(); + + const transactionDataMock = {}; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toMatchObject({ + quotes: [QUOTE_MOCK], + }); + }); + + it('clears quotes in state if no source amounts', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + sourceAmounts: undefined, + }, + }); + + const transactionDataMock = { + quotes: [QUOTE_MOCK], + }; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + }); + }); + + it('throws if no transaction', async () => { + getTransactionMock.mockReturnValue(undefined); + + await expect(run()).rejects.toThrow('Transaction not found'); + }); + + it('clears quotes in state if strategy throws', async () => { + getQuotesMock.mockRejectedValue(new Error('Strategy error')); + + await run(); + + const transactionDataMock = { + quotes: [QUOTE_MOCK], + }; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toMatchObject({ + quotes: [], + }); + }); + + it('does nothing if no payment token', async () => { + await run({ + transactionData: { + ...TRANSACTION_DATA_MOCK, + paymentToken: undefined, + }, + }); + + expect(updateTransactionDataMock).not.toHaveBeenCalled(); + }); + + it('gets quotes from strategy', async () => { + await run(); + + expect(getQuotesMock).toHaveBeenCalledWith({ + messenger, + requests: [ + { + from: TRANSACTION_META_MOCK.txParams.from, + sourceBalanceRaw: TRANSACTION_DATA_MOCK.paymentToken?.balanceRaw, + sourceTokenAmount: + TRANSACTION_DATA_MOCK.sourceAmounts?.[0].sourceAmountRaw, + sourceChainId: TRANSACTION_DATA_MOCK.paymentToken?.chainId, + sourceTokenAddress: TRANSACTION_DATA_MOCK.paymentToken?.address, + targetAmountMinimum: TRANSACTION_DATA_MOCK.tokens?.[0].amountRaw, + targetChainId: TRANSACTION_DATA_MOCK.tokens?.[0].chainId, + targetTokenAddress: TRANSACTION_DATA_MOCK.tokens?.[0].address, + }, + ], + }); + }); + + it('updates totals in state', async () => { + calculateTotalsMock.mockReturnValue(TOTALS_MOCK); + + await run(); + + const transactionDataMock = {}; + + updateTransactionDataMock.mock.calls[0][1](transactionDataMock); + + expect(transactionDataMock).toStrictEqual( + expect.objectContaining({ totals: TOTALS_MOCK }), + ); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/quotes.ts b/packages/transaction-pay-controller/src/utils/quotes.ts new file mode 100644 index 00000000000..eb4b3f37c92 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/quotes.ts @@ -0,0 +1,91 @@ +import type { Hex, Json } from '@metamask/utils'; +import { createModuleLogger } from '@metamask/utils'; + +import { getStrategy } from './strategy'; +import { calculateTotals } from './totals'; +import { getTransaction } from './transaction'; +import { projectLogger } from '../logger'; +import type { + QuoteRequest, + TransactionData, + TransactionPayControllerMessenger, + TransactionPayQuote, + UpdateTransactionDataCallback, +} from '../types'; + +const log = createModuleLogger(projectLogger, 'quotes'); + +export type UpdateQuotesRequest = { + messenger: TransactionPayControllerMessenger; + transactionData: TransactionData | undefined; + transactionId: string; + updateTransactionData: UpdateTransactionDataCallback; +}; + +/** + * Update the quotes for a specific transaction. + * + * @param request - Request parameters. + */ +export async function updateQuotes(request: UpdateQuotesRequest) { + const { messenger, transactionData, transactionId, updateTransactionData } = + request; + + const transaction = getTransaction(transactionId, messenger); + + log('Updating quotes', { transactionId }); + + if (!transaction || !transactionData) { + throw new Error('Transaction not found'); + } + + const { paymentToken, sourceAmounts, tokens } = transactionData; + + if (!paymentToken) { + return; + } + + const requests: QuoteRequest[] = (sourceAmounts ?? []).map( + (sourceAmount, i) => { + const token = tokens[i]; + + return { + from: transaction.txParams.from as Hex, + sourceBalanceRaw: paymentToken.balanceRaw, + sourceTokenAmount: sourceAmount.sourceAmountRaw, + sourceChainId: paymentToken.chainId, + sourceTokenAddress: paymentToken.address, + targetAmountMinimum: token.amountRaw, + targetChainId: token.chainId, + targetTokenAddress: token.address, + }; + }, + ); + + if (!requests?.length) { + log('No quote requests', { transactionId }); + } + + let quotes: TransactionPayQuote[] | undefined = []; + + const strategy = await getStrategy(messenger as never, transaction); + + try { + quotes = requests?.length + ? ((await strategy.getQuotes({ + messenger, + requests, + })) as TransactionPayQuote[]) + : []; + } catch (error) { + log('Error fetching quotes', { error, transactionId }); + } + + log('Updated', { transactionId, quotes }); + + updateTransactionData(transactionId, (data) => { + data.quotes = quotes as never; + data.totals = calculateTotals(quotes as never, tokens, messenger); + data.isLoading = false; + }); +} diff --git a/packages/transaction-pay-controller/src/utils/required-fiat.test.ts b/packages/transaction-pay-controller/src/utils/required-fiat.test.ts new file mode 100644 index 00000000000..d767ea1c90b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/required-fiat.test.ts @@ -0,0 +1,77 @@ +import { calculateFiat } from './required-fiat'; +import { getTokenFiatRate } from './token'; +import type { + TransactionPayControllerMessenger, + TransactionTokenRequired, +} from '../types'; + +jest.mock('./token'); + +const TRANSACTION_TOKEN_MOCK: TransactionTokenRequired = { + address: '0x123', + allowUnderMinimum: false, + amountHuman: '1.23', + amountRaw: '1230000', + balanceHuman: '4.56', + balanceRaw: '4560000', + chainId: '0x1', + decimals: 6, + skipIfBalance: false, + symbol: 'TST', +}; + +const MESSENGER_MOCK = {} as TransactionPayControllerMessenger; + +describe('Required Fiat Utils', () => { + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + + beforeEach(() => { + jest.resetAllMocks(); + + getTokenFiatRateMock.mockReturnValue({ + usdRate: '2.0', + fiatRate: '3.0', + }); + }); + + describe('calculateFiat', () => { + it('calculates fiat properties', () => { + const result = calculateFiat(TRANSACTION_TOKEN_MOCK, MESSENGER_MOCK); + + expect(result).toStrictEqual({ + amountFiat: '3.69', + amountUsd: '2.46', + balanceFiat: '13.68', + balanceUsd: '9.12', + }); + }); + + it('returns undefined if no fiat rates', () => { + getTokenFiatRateMock.mockReturnValue(undefined); + + const result = calculateFiat(TRANSACTION_TOKEN_MOCK, MESSENGER_MOCK); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if no fiat rate', () => { + getTokenFiatRateMock.mockReturnValue({ + usdRate: '2.0', + } as never); + + const result = calculateFiat(TRANSACTION_TOKEN_MOCK, MESSENGER_MOCK); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if no usd rate', () => { + getTokenFiatRateMock.mockReturnValue({ + fiatRate: '2.0', + } as never); + + const result = calculateFiat(TRANSACTION_TOKEN_MOCK, MESSENGER_MOCK); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/required-fiat.ts b/packages/transaction-pay-controller/src/utils/required-fiat.ts new file mode 100644 index 00000000000..d0b3973ef78 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/required-fiat.ts @@ -0,0 +1,51 @@ +import { BigNumber } from 'bignumber.js'; + +import { getTokenFiatRate } from './token'; +import type { + TransactionPayControllerMessenger, + TransactionTokenRequired, +} from '../types'; + +/** + * Calculate fiat rates for a specific token. + * + * @param token - The required token. + * @param messenger - The transaction pay controller messenger. + * @returns The fiat rates or undefined if the rates are not available. + */ +export function calculateFiat( + token: TransactionTokenRequired, + messenger: TransactionPayControllerMessenger, +) { + const { address, amountHuman, balanceHuman, chainId } = token; + + const { usdRate, fiatRate } = + getTokenFiatRate(messenger, address, chainId) ?? {}; + + if (usdRate === undefined || fiatRate === undefined) { + return undefined; + } + + const amountFiat = new BigNumber(amountHuman) + .multipliedBy(fiatRate) + .toString(10); + + const amountUsd = new BigNumber(amountHuman) + .multipliedBy(usdRate) + .toString(10); + + const balanceFiat = new BigNumber(balanceHuman) + .multipliedBy(fiatRate) + .toString(10); + + const balanceUsd = new BigNumber(balanceHuman) + .multipliedBy(usdRate) + .toString(10); + + return { + amountFiat, + amountUsd, + balanceFiat, + balanceUsd, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.test.ts b/packages/transaction-pay-controller/src/utils/required-tokens.test.ts new file mode 100644 index 00000000000..4782216b5b3 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/required-tokens.test.ts @@ -0,0 +1,95 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { parseRequiredTokens } from './required-tokens'; +import { getTokenBalance, getTokenInfo } from './token'; +import type { TransactionPayControllerMessenger } from '../types'; + +jest.mock('./token'); + +const TRANSACTION_META_MOCK = { + chainId: '0x1' as Hex, + txParams: { + data: '0xa9059cbb0000000000000000000000005a52e96bacdabb82fd05763e25335261b270efcb000000000000000000000000000000000000000000000000000000000001E240', + to: '0x123', + }, +} as TransactionMeta; + +const MESSENGER_MOCK = {} as TransactionPayControllerMessenger; + +describe('Required Tokens Utils', () => { + const getTokenBalanceMock = jest.mocked(getTokenBalance); + const getTokenInfoMock = jest.mocked(getTokenInfo); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + describe('parseRequiredTokens', () => { + it('returns token transfer required token', () => { + getTokenInfoMock.mockReturnValue({ decimals: 3, symbol: 'TST' }); + getTokenBalanceMock.mockReturnValue('789000'); + + const result = parseRequiredTokens(TRANSACTION_META_MOCK, MESSENGER_MOCK); + + expect(result).toStrictEqual([ + { + address: TRANSACTION_META_MOCK.txParams.to, + allowUnderMinimum: false, + amountHuman: '123.456', + amountRaw: '123456', + balanceHuman: '789', + balanceRaw: '789000', + chainId: TRANSACTION_META_MOCK.chainId, + decimals: 3, + skipIfBalance: false, + symbol: 'TST', + }, + ]); + }); + + it('returns empty array if no to', () => { + const result = parseRequiredTokens( + { + ...TRANSACTION_META_MOCK, + txParams: { ...TRANSACTION_META_MOCK.txParams, to: undefined }, + }, + MESSENGER_MOCK, + ); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array if no data', () => { + const result = parseRequiredTokens( + { + ...TRANSACTION_META_MOCK, + txParams: { ...TRANSACTION_META_MOCK.txParams, data: undefined }, + }, + MESSENGER_MOCK, + ); + + expect(result).toStrictEqual([]); + }); + + it('returns empty array if not transfer', () => { + const result = parseRequiredTokens( + { + ...TRANSACTION_META_MOCK, + txParams: { ...TRANSACTION_META_MOCK.txParams, data: '0x12345678' }, + }, + MESSENGER_MOCK, + ); + + expect(result).toStrictEqual([]); + }); + + it('returns undefined if token info not found', () => { + getTokenInfoMock.mockReturnValue(undefined); + + const result = parseRequiredTokens(TRANSACTION_META_MOCK, MESSENGER_MOCK); + + expect(result).toStrictEqual([]); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/required-tokens.ts b/packages/transaction-pay-controller/src/utils/required-tokens.ts new file mode 100644 index 00000000000..fca63c60827 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/required-tokens.ts @@ -0,0 +1,130 @@ +import { Interface } from '@ethersproject/abi'; +import { toHex } from '@metamask/controller-utils'; +import { abiERC20 } from '@metamask/metamask-eth-abis'; +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { getTokenBalance, getTokenInfo } from './token'; +import type { + TransactionPayControllerMessenger, + TransactionTokenRequired, +} from '../types'; + +/** + * Parse required tokens from a transaction. + * + * @param transaction - Transaction metadata. + * @param messenger - Controller messenger. + * @returns An array of required tokens. + */ +export function parseRequiredTokens( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): TransactionTokenRequired[] { + return [parseTokenTransfer(transaction, messenger)].filter( + Boolean, + ) as TransactionTokenRequired[]; +} + +/** + * Parse a required token from a token transfer. + * + * @param transaction - Transaction metadata. + * @param messenger - Controller messenger. + * @returns The required token or undefined if the transaction is not a token transfer. + */ +function parseTokenTransfer( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): TransactionTokenRequired | undefined { + const { txParams } = transaction; + const { data } = txParams; + const to = txParams.to as Hex | undefined; + + if (!to || !data) { + return undefined; + } + + let transferAmount: Hex | undefined; + + try { + const result = new Interface(abiERC20).decodeFunctionData('transfer', data); + transferAmount = toHex(result._value); + } catch { + // Intentionally empty + } + + if (transferAmount === undefined) { + return undefined; + } + + return getTokenProperties(transaction, to, transferAmount, messenger); +} + +/** + * Get the full token properties for a specific token and amount. + * + * @param transaction - Transaction metadata. + * @param tokenAddress - Token address. + * @param amount - Token amount in hexadecimal format. + * @param messenger - Controller messenger. + * @returns The full token properties or undefined if the token data could not be retrieved. + */ +function getTokenProperties( + transaction: TransactionMeta, + tokenAddress: Hex, + amount: Hex, + messenger: TransactionPayControllerMessenger, +): TransactionTokenRequired | undefined { + const { chainId, txParams } = transaction; + const from = txParams.from as Hex; + + const { decimals: tokenDecimals, symbol } = + getTokenInfo(messenger, tokenAddress, chainId) ?? {}; + + const tokenBalance = getTokenBalance(messenger, from, chainId, tokenAddress); + + if (!amount || tokenDecimals === undefined || !symbol) { + return undefined; + } + + const { amountHuman: balanceHuman, amountRaw: balanceRaw } = calculateAmounts( + tokenBalance, + tokenDecimals, + ); + + const { amountHuman, amountRaw } = calculateAmounts(amount, tokenDecimals); + + return { + address: tokenAddress, + allowUnderMinimum: false, + amountHuman, + amountRaw, + balanceHuman, + balanceRaw, + chainId, + decimals: tokenDecimals, + skipIfBalance: false, + symbol, + }; +} + +/** + * Calculates human and raw amounts for a value based on the token decimals. + * + * @param amountRawInput - The raw input. + * @param decimals - The number of decimals for the token. + * @returns An object containing both the human-readable and raw amounts as strings. + */ +function calculateAmounts(amountRawInput: BigNumber.Value, decimals: number) { + const amountRawValue = new BigNumber(amountRawInput); + const amountHumanValue = amountRawValue.shiftedBy(-decimals); + const amountRaw = amountRawValue.toFixed(0); + const amountHuman = amountHumanValue.toString(10); + + return { + amountHuman, + amountRaw, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.test.ts b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts new file mode 100644 index 00000000000..0e7c95e5a6b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/source-amounts.test.ts @@ -0,0 +1,161 @@ +import { updateSourceAmounts } from './source-amounts'; +import { getTokenFiatRate } from './token'; +import type { TransactionPaymentToken, TransactionToken } from '..'; +import type { TransactionData } from '../types'; + +jest.mock('./token'); + +const PAYMENT_TOKEN_MOCK: TransactionPaymentToken = { + address: '0x123', + balanceFiat: '2.46', + balanceHuman: '1.23', + balanceRaw: '1230000', + balanceUsd: '3.69', + chainId: '0x1', + decimals: 6, + symbol: 'TST', +}; + +const TRANSACTION_TOKEN_MOCK: TransactionToken = { + address: '0x456', + allowUnderMinimum: false, + amountFiat: '1.23', + amountHuman: '0.5', + amountRaw: '500000', + amountUsd: '6.0', + balanceFiat: '2.46', + balanceHuman: '1.23', + balanceRaw: '1230000', + balanceUsd: '3.69', + chainId: '0x1', + decimals: 6, + skipIfBalance: false, + symbol: 'TST2', +}; + +const TRANSACTION_ID_MOCK = '123-456'; + +describe('Source Amounts Utils', () => { + const getTokenFiatRateMock = jest.mocked(getTokenFiatRate); + + beforeEach(() => { + jest.resetAllMocks(); + + getTokenFiatRateMock.mockReturnValue({ fiatRate: '2.0', usdRate: '3.0' }); + }); + + describe('updateSourceAmounts', () => { + it('updated source amounts', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toStrictEqual([ + { + sourceAmountHuman: '2', + sourceAmountRaw: '2000000', + targetTokenAddress: TRANSACTION_TOKEN_MOCK.address, + }, + ]); + }); + + it('returns empty array if payment token matches', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + address: PAYMENT_TOKEN_MOCK.address, + chainId: PAYMENT_TOKEN_MOCK.chainId, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('returns empty array if skipIfBalance and has balance', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + balanceUsd: TRANSACTION_TOKEN_MOCK.amountUsd, + skipIfBalance: true, + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('returns empty array if no payment token fiat rate', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + getTokenFiatRateMock.mockReturnValue(undefined); + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('returns empty array if zero amount', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [ + { + ...TRANSACTION_TOKEN_MOCK, + amountRaw: '0', + }, + ], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toStrictEqual([]); + }); + + it('does nothing if no payment token', () => { + const transactionData: TransactionData = { + isLoading: false, + tokens: [TRANSACTION_TOKEN_MOCK], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toBeUndefined(); + }); + + it('does nothing if no tokens', () => { + const transactionData: TransactionData = { + isLoading: false, + paymentToken: PAYMENT_TOKEN_MOCK, + tokens: [], + }; + + updateSourceAmounts(TRANSACTION_ID_MOCK, transactionData, {} as never); + + expect(transactionData.sourceAmounts).toBeUndefined(); + }); + + // eslint-disable-next-line jest/expect-expect + it('does nothing if no transaction data', () => { + updateSourceAmounts(TRANSACTION_ID_MOCK, undefined, {} as never); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/source-amounts.ts b/packages/transaction-pay-controller/src/utils/source-amounts.ts new file mode 100644 index 00000000000..c795c6db936 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/source-amounts.ts @@ -0,0 +1,107 @@ +import { createModuleLogger } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import { getTokenFiatRate } from './token'; +import type { + TransactionPayControllerMessenger, + TransactionPaymentToken, + TransactionToken, +} from '..'; +import { projectLogger } from '../logger'; +import type { SourceAmountValues, TransactionData } from '../types'; + +const log = createModuleLogger(projectLogger, 'source-amounts'); + +/** + * Update the source amounts for a transaction. + * + * @param transactionId - ID of the transaction to update. + * @param transactionData - Existing transaction data. + * @param messenger - Controller messenger. + */ +export function updateSourceAmounts( + transactionId: string, + transactionData: TransactionData | undefined, + messenger: TransactionPayControllerMessenger, +) { + if (!transactionData) { + return; + } + + const { paymentToken, tokens } = transactionData; + + if (!tokens.length || !paymentToken) { + return; + } + + const sourceAmounts = tokens + .map((t) => calculateSourceAmount(paymentToken, t, messenger)) + .filter(Boolean) as SourceAmountValues[]; + + log('Updated source amounts', { transactionId, sourceAmounts }); + + transactionData.sourceAmounts = sourceAmounts; +} + +/** + * Calculate the required source amount for a payment token to cover a target token. + * + * @param paymentToken - Selected payment token. + * @param token - Target token to cover. + * @param messenger - Controller messenger. + * @returns The source amount or undefined if calculation failed. + */ +function calculateSourceAmount( + paymentToken: TransactionPaymentToken, + token: TransactionToken, + messenger: TransactionPayControllerMessenger, +): SourceAmountValues | undefined { + const paymentTokenFiatRate = getTokenFiatRate( + messenger, + paymentToken.address, + paymentToken.chainId, + ); + + if (!paymentTokenFiatRate) { + return undefined; + } + + const hasBalance = new BigNumber(token.balanceUsd).gte(token.amountUsd); + + const isSameTokenSelected = + token.address.toLowerCase() === paymentToken.address.toLowerCase() && + token.chainId === paymentToken.chainId; + + if (token.skipIfBalance && hasBalance) { + log('Skipping token as sufficient balance', { + tokenAddress: token.address, + }); + return undefined; + } + + if (isSameTokenSelected) { + log('Skipping token as same as payment token'); + return undefined; + } + + const sourceAmountHumanValue = new BigNumber(token.amountUsd).div( + paymentTokenFiatRate.usdRate, + ); + + const sourceAmountHuman = sourceAmountHumanValue.toString(10); + + const sourceAmountRaw = sourceAmountHumanValue + .shiftedBy(paymentToken.decimals) + .toFixed(0); + + if (token.amountRaw === '0') { + log('Skipping token as zero amount', { tokenAddress: token.address }); + return undefined; + } + + return { + sourceAmountHuman, + sourceAmountRaw, + targetTokenAddress: token.address, + }; +} diff --git a/packages/transaction-pay-controller/src/utils/strategy.test.ts b/packages/transaction-pay-controller/src/utils/strategy.test.ts new file mode 100644 index 00000000000..e58a5bb483b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/strategy.test.ts @@ -0,0 +1,61 @@ +import { Messenger } from '@metamask/base-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import { getStrategy } from './strategy'; +import type { TransactionPayPublishHookMessenger } from '..'; +import { TransactionPayStrategy } from '../constants'; +import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; +import { TestStrategy } from '../strategy/test/TestStrategy'; +import { RelayStrategy } from '../strategy/relay/RelayStrategy'; + +const TRANSACTION_META_MOCK = {} as TransactionMeta; + +describe('Strategy Utils', () => { + let messenger: TransactionPayPublishHookMessenger; + const getStrategyMessengerMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + messenger = new Messenger(); + + messenger.registerActionHandler( + 'TransactionPayController:getStrategy', + getStrategyMessengerMock as never, + ); + }); + + describe('getStrategy', () => { + it('returns TestStrategy if strategy name is Test', async () => { + getStrategyMessengerMock.mockResolvedValue(TransactionPayStrategy.Test); + + const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK); + + expect(strategy).toBeInstanceOf(TestStrategy); + }); + + it('returns BridgeStrategy if strategy name is Bridge', async () => { + getStrategyMessengerMock.mockResolvedValue(TransactionPayStrategy.Bridge); + + const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK); + + expect(strategy).toBeInstanceOf(BridgeStrategy); + }); + + it('returns RelayStrategy if strategy name is Relay', async () => { + getStrategyMessengerMock.mockResolvedValue(TransactionPayStrategy.Relay); + + const strategy = await getStrategy(messenger, TRANSACTION_META_MOCK); + + expect(strategy).toBeInstanceOf(RelayStrategy); + }); + + it('throws if strategy name is unknown', async () => { + getStrategyMessengerMock.mockResolvedValue('UnknownStrategy'); + + await expect( + getStrategy(messenger, TRANSACTION_META_MOCK), + ).rejects.toThrow('Unknown strategy: UnknownStrategy'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/strategy.ts b/packages/transaction-pay-controller/src/utils/strategy.ts new file mode 100644 index 00000000000..4003c562939 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/strategy.ts @@ -0,0 +1,39 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { TransactionPayPublishHookMessenger } from '..'; +import { TransactionPayStrategy } from '../constants'; +import { BridgeStrategy } from '../strategy/bridge/BridgeStrategy'; +import { RelayStrategy } from '../strategy/relay/RelayStrategy'; +import { TestStrategy } from '../strategy/test/TestStrategy'; +import type { PayStrategy } from '../types'; + +/** + * Get the payment strategy instance. + * + * @param messenger - Controller messenger + * @param transaction - Transaction to get the strategy for. + * @returns The payment strategy instance. + */ +export async function getStrategy( + messenger: TransactionPayPublishHookMessenger, + transaction: TransactionMeta, +): Promise> { + const strategyName = await messenger.call( + 'TransactionPayController:getStrategy', + transaction, + ); + + switch (strategyName) { + case TransactionPayStrategy.Bridge: + return new BridgeStrategy() as never; + + case TransactionPayStrategy.Relay: + return new RelayStrategy() as never; + + case TransactionPayStrategy.Test: + return new TestStrategy() as never; + + default: + throw new Error(`Unknown strategy: ${strategyName as string}`); + } +} diff --git a/packages/transaction-pay-controller/src/utils/token.test.ts b/packages/transaction-pay-controller/src/utils/token.test.ts new file mode 100644 index 00000000000..b8d0113264e --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/token.test.ts @@ -0,0 +1,261 @@ +import { Messenger } from '@metamask/base-controller'; +import type { Hex } from '@metamask/utils'; + +import { getTokenBalance, getTokenInfo, getTokenFiatRate } from './token'; +import type { TransactionPayControllerMessenger } from '..'; +import type { AllowedActions } from '../types'; + +const TOKEN_ADDRESS_MOCK = '0x559B65722aD62AD6DAC4Fa5a1c6B23A2e8ce57Ec'; +const CHAIN_ID_MOCK = '0x1'; +const DECIMALS_MOCK = 6; +const BALANCE_MOCK = '0x123'; +const FROM_MOCK = '0x456'; +const NETWORK_CLIENT_ID_MOCK = '123-456'; +const TICKER_MOCK = 'TST'; +const SYMBOL_MOCK = 'TEST'; + +describe('Token Utils', () => { + let baseMessenger: Messenger; + let messengerMock: TransactionPayControllerMessenger; + + const getTokensControllerStateMock = jest.fn(); + const getTokenBalanceControllerStateMock = jest.fn(); + const findNetworkClientIdByChainIdMock = jest.fn(); + const getNetworkClientByIdMock = jest.fn(); + const getTokenRatesControllerStateMock = jest.fn(); + const getCurrencyRateControllerStateMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + baseMessenger = new Messenger(); + + baseMessenger.registerActionHandler( + 'TokensController:getState', + getTokensControllerStateMock, + ); + + baseMessenger.registerActionHandler( + 'TokenBalancesController:getState', + getTokenBalanceControllerStateMock, + ); + + baseMessenger.registerActionHandler( + 'NetworkController:findNetworkClientIdByChainId', + findNetworkClientIdByChainIdMock, + ); + + baseMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientByIdMock, + ); + + baseMessenger.registerActionHandler( + 'TokenRatesController:getState', + getTokenRatesControllerStateMock, + ); + + baseMessenger.registerActionHandler( + 'CurrencyRateController:getState', + getCurrencyRateControllerStateMock, + ); + + messengerMock = baseMessenger.getRestricted({ + name: 'TransactionPayController', + allowedActions: [ + 'CurrencyRateController:getState', + 'NetworkController:findNetworkClientIdByChainId', + 'NetworkController:getNetworkClientById', + 'TokenBalancesController:getState', + 'TokenRatesController:getState', + 'TokensController:getState', + ], + allowedEvents: [], + }); + }); + + describe('getTokenInfo', () => { + it('returns decimals and symbol from controller state', () => { + getTokensControllerStateMock.mockReturnValue({ + allTokens: { + [CHAIN_ID_MOCK]: { + test123: [ + { + address: TOKEN_ADDRESS_MOCK.toLowerCase(), + decimals: DECIMALS_MOCK, + symbol: SYMBOL_MOCK, + }, + ], + }, + }, + }); + + const result = getTokenInfo( + messengerMock, + TOKEN_ADDRESS_MOCK, + CHAIN_ID_MOCK, + ); + + expect(result).toStrictEqual({ + decimals: DECIMALS_MOCK, + symbol: SYMBOL_MOCK, + }); + }); + + it('returns undefined if token is not found', () => { + getTokensControllerStateMock.mockReturnValue({}); + + const result = getTokenInfo( + messengerMock, + TOKEN_ADDRESS_MOCK, + CHAIN_ID_MOCK, + ); + + expect(result).toBeUndefined(); + }); + }); + + describe('getTokenBalance', () => { + it('returns balance from controller state', () => { + getTokenBalanceControllerStateMock.mockReturnValue({ + tokenBalances: { + [FROM_MOCK]: { + [CHAIN_ID_MOCK]: { + [TOKEN_ADDRESS_MOCK]: BALANCE_MOCK, + }, + }, + }, + }); + + const result = getTokenBalance( + messengerMock, + FROM_MOCK, + CHAIN_ID_MOCK, + TOKEN_ADDRESS_MOCK.toLowerCase() as Hex, + ); + + expect(result).toBe('291'); + }); + + it('returns zero if token not found', () => { + getTokenBalanceControllerStateMock.mockReturnValue({ + tokenBalances: {}, + }); + + const result = getTokenBalance( + messengerMock, + FROM_MOCK, + CHAIN_ID_MOCK, + TOKEN_ADDRESS_MOCK.toLowerCase() as Hex, + ); + + expect(result).toBe('0'); + }); + }); + + describe('getTokenFiatRate', () => { + it('returns fiat rates', () => { + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + getNetworkClientByIdMock.mockReturnValue({ + configuration: { ticker: TICKER_MOCK }, + }); + + getTokenRatesControllerStateMock.mockReturnValue({ + marketData: { + [CHAIN_ID_MOCK]: { + [TOKEN_ADDRESS_MOCK]: { + price: 2.0, + }, + }, + }, + }); + + getCurrencyRateControllerStateMock.mockReturnValue({ + currencyRates: { + [TICKER_MOCK]: { + conversionRate: 3.0, + usdConversionRate: 4.0, + }, + }, + }); + + const result = getTokenFiatRate( + messengerMock, + TOKEN_ADDRESS_MOCK as Hex, + CHAIN_ID_MOCK, + ); + + expect(result).toStrictEqual({ + fiatRate: '6', + usdRate: '8', + }); + }); + + it('returns undefined if no network configuration', () => { + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + getNetworkClientByIdMock.mockReturnValue(undefined); + + const result = getTokenFiatRate( + messengerMock, + TOKEN_ADDRESS_MOCK as Hex, + CHAIN_ID_MOCK, + ); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if no price', () => { + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + getNetworkClientByIdMock.mockReturnValue({ + configuration: { ticker: TICKER_MOCK }, + }); + + getTokenRatesControllerStateMock.mockReturnValue({ + marketData: { + [CHAIN_ID_MOCK]: {}, + }, + }); + + const result = getTokenFiatRate( + messengerMock, + TOKEN_ADDRESS_MOCK as Hex, + CHAIN_ID_MOCK, + ); + + expect(result).toBeUndefined(); + }); + + it('returns undefined if no currency rate', () => { + findNetworkClientIdByChainIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); + + getNetworkClientByIdMock.mockReturnValue({ + configuration: { ticker: TICKER_MOCK }, + }); + + getTokenRatesControllerStateMock.mockReturnValue({ + marketData: { + [CHAIN_ID_MOCK]: { + [TOKEN_ADDRESS_MOCK]: { + price: 2.0, + }, + }, + }, + }); + + getCurrencyRateControllerStateMock.mockReturnValue({ + currencyRates: {}, + }); + + const result = getTokenFiatRate( + messengerMock, + TOKEN_ADDRESS_MOCK as Hex, + CHAIN_ID_MOCK, + ); + + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/token.ts b/packages/transaction-pay-controller/src/utils/token.ts new file mode 100644 index 00000000000..7fff739e7d0 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/token.ts @@ -0,0 +1,128 @@ +import { toChecksumHexAddress } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; + +import type { FiatRates, TransactionPayControllerMessenger } from '../types'; + +/** + * Get the token balance for a specific account and token. + * + * @param messenger - Controller messenger. + * @param account - Address of the account. + * @param chainId - Id of the chain. + * @param tokenAddress - Address of the token contract. + * @returns The token balance as a BigNumber. + */ +export function getTokenBalance( + messenger: TransactionPayControllerMessenger, + account: Hex, + chainId: Hex, + tokenAddress: Hex, +): string { + const controllerState = messenger.call('TokenBalancesController:getState'); + const normalizedAccount = account.toLowerCase() as Hex; + const normalizedTokenAddress = toChecksumHexAddress(tokenAddress) as Hex; + + const balanceHex = + controllerState.tokenBalances?.[normalizedAccount]?.[chainId]?.[ + normalizedTokenAddress + ]; + + return new BigNumber(balanceHex ?? '0x0', 16).toString(10); +} + +/** + * Get the token decimals for a specific token. + * + * @param messenger - Controller messenger. + * @param tokenAddress - Address of the token contract. + * @param chainId - Id of the chain. + * @returns The token decimals or undefined if the token is not found. + */ +export function getTokenInfo( + messenger: TransactionPayControllerMessenger, + tokenAddress: Hex, + chainId: Hex, +): { decimals: number; symbol: string } | undefined { + const controllerState = messenger.call('TokensController:getState'); + const normalizedTokenAddress = tokenAddress.toLowerCase() as Hex; + + const token = Object.values(controllerState.allTokens?.[chainId] ?? {}) + .flat() + .find((t) => t.address.toLowerCase() === normalizedTokenAddress); + + return token ? { decimals: token.decimals, symbol: token.symbol } : undefined; +} + +/** + * Calculate fiat rates for a specific token. + * + * @param messenger - Controller messenger. + * @param tokenAddress - Address of the token contract. + * @param chainId - Id of the chain. + * @returns An object containing the USD and fiat rates, or undefined if rates are not available. + */ +export function getTokenFiatRate( + messenger: TransactionPayControllerMessenger, + tokenAddress: Hex, + chainId: Hex, +): FiatRates | undefined { + let ticker; + + try { + const networkClientId = messenger.call( + 'NetworkController:findNetworkClientIdByChainId', + chainId, + ); + + const networkConfiguration = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + + ticker = networkConfiguration.configuration.ticker; + } catch { + // Intentionally empty + } + + if (!ticker) { + return undefined; + } + + const rateControllerState = messenger.call('TokenRatesController:getState'); + + const currencyRateControllerState = messenger.call( + 'CurrencyRateController:getState', + ); + + const normalizedTokenAddress = toChecksumHexAddress(tokenAddress) as Hex; + + const tokenToNativeRate = + rateControllerState.marketData?.[chainId]?.[normalizedTokenAddress]?.price; + + if (tokenToNativeRate === undefined) { + return undefined; + } + + const { + conversionRate: nativeToFiatRate, + usdConversionRate: nativeToUsdRate, + } = currencyRateControllerState.currencyRates?.[ticker] ?? { + conversionRate: null, + usdConversionRate: null, + }; + + if (nativeToFiatRate === null || nativeToUsdRate === null) { + return undefined; + } + + const usdRate = new BigNumber(tokenToNativeRate) + .multipliedBy(nativeToUsdRate) + .toString(10); + + const fiatRate = new BigNumber(tokenToNativeRate) + .multipliedBy(nativeToFiatRate) + .toString(10); + + return { usdRate, fiatRate }; +} diff --git a/packages/transaction-pay-controller/src/utils/totals.test.ts b/packages/transaction-pay-controller/src/utils/totals.test.ts new file mode 100644 index 00000000000..2697b58fcf5 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/totals.test.ts @@ -0,0 +1,125 @@ +import { calculateTotals } from './totals'; +import type { TransactionPayControllerMessenger } from '..'; +import type { + QuoteRequest, + TransactionPayQuote, + TransactionToken, +} from '../types'; + +const MESSENGER_MOCK = {} as TransactionPayControllerMessenger; + +const QUOTE_1_MOCK: TransactionPayQuote = { + dust: { + fiat: '0.00', + usd: '0.00', + }, + estimatedDuration: 123, + fees: { + provider: { + fiat: '1.11', + usd: '2.22', + }, + sourceNetwork: { + fiat: '3.33', + usd: '4.44', + }, + targetNetwork: { + fiat: '5.55', + usd: '6.66', + }, + }, + original: undefined, + request: {} as QuoteRequest, +}; + +const TOKEN_1_MOCK = { + amountFiat: '1.11', + amountUsd: '2.22', +} as TransactionToken; + +const TOKEN_2_MOCK = { + amountFiat: '3.33', + amountUsd: '4.44', +} as TransactionToken; + +const QUOTE_2_MOCK: TransactionPayQuote = { + dust: { + fiat: '0.00', + usd: '0.00', + }, + estimatedDuration: 234, + fees: { + provider: { + fiat: '7.77', + usd: '8.88', + }, + sourceNetwork: { + fiat: '9.99', + usd: '10.10', + }, + targetNetwork: { + fiat: '11.11', + usd: '12.12', + }, + }, + original: undefined, + request: {} as QuoteRequest, +}; + +describe('Totals Utils', () => { + describe('calculateTotals', () => { + it('returns estimated duration', () => { + const result = calculateTotals( + [QUOTE_1_MOCK, QUOTE_2_MOCK], + [], + MESSENGER_MOCK, + ); + + expect(result.estimatedDuration).toBe(357); + }); + + it('returns total', () => { + const result = calculateTotals( + [QUOTE_1_MOCK, QUOTE_2_MOCK], + [TOKEN_1_MOCK, TOKEN_2_MOCK], + MESSENGER_MOCK, + ); + + expect(result.total.fiat).toBe('43.3'); + expect(result.total.usd).toBe('51.08'); + }); + + it('returns provider fees', () => { + const result = calculateTotals( + [QUOTE_1_MOCK, QUOTE_2_MOCK], + [], + MESSENGER_MOCK, + ); + + expect(result.fees.provider.fiat).toBe('8.88'); + expect(result.fees.provider.usd).toBe('11.1'); + }); + + it('returns source network fees', () => { + const result = calculateTotals( + [QUOTE_1_MOCK, QUOTE_2_MOCK], + [], + MESSENGER_MOCK, + ); + + expect(result.fees.sourceNetwork.fiat).toBe('13.32'); + expect(result.fees.sourceNetwork.usd).toBe('14.54'); + }); + + it('returns target network fees', () => { + const result = calculateTotals( + [QUOTE_1_MOCK, QUOTE_2_MOCK], + [], + MESSENGER_MOCK, + ); + + expect(result.fees.targetNetwork.fiat).toBe('16.66'); + expect(result.fees.targetNetwork.usd).toBe('18.78'); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/totals.ts b/packages/transaction-pay-controller/src/utils/totals.ts new file mode 100644 index 00000000000..c41524fa033 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/totals.ts @@ -0,0 +1,110 @@ +import { BigNumber } from 'bignumber.js'; + +import type { + TransactionPayControllerMessenger, + TransactionPayQuote, + TransactionPayTotals, + TransactionToken, +} from '../types'; + +/** + * Calculate totals for a list of quotes and tokens. + * + * @param quotes - List of bridge quotes. + * @param tokens - List of required transaction tokens. + * @param _messenger - Controller messenger. + * @returns The calculated totals in USD and fiat currency. + */ +export function calculateTotals( + quotes: TransactionPayQuote[], + tokens: TransactionToken[], + _messenger: TransactionPayControllerMessenger, +): TransactionPayTotals { + const providerFeeFiat = sumProperty( + quotes, + (quote) => quote.fees.provider.fiat, + ); + + const providerFeeUsd = sumProperty( + quotes, + (quote) => quote.fees.provider.usd, + ); + + const sourceNetworkFeeFiat = sumProperty( + quotes, + (quote) => quote.fees.sourceNetwork.fiat, + ); + + const sourceNetworkFeeUsd = sumProperty( + quotes, + (quote) => quote.fees.sourceNetwork.usd, + ); + + const targetNetworkFeeFiat = sumProperty( + quotes, + (quote) => quote.fees.targetNetwork.fiat, + ); + + const targetNetworkFeeUsd = sumProperty( + quotes, + (quote) => quote.fees.targetNetwork.usd, + ); + + const amountFiat = sumProperty(tokens, (token) => token.amountFiat); + const amountUsd = sumProperty(tokens, (token) => token.amountUsd); + + const totalFiat = new BigNumber(providerFeeFiat) + .plus(sourceNetworkFeeFiat) + .plus(targetNetworkFeeFiat) + .plus(amountFiat) + .toString(10); + + const totalUsd = new BigNumber(providerFeeUsd) + .plus(sourceNetworkFeeUsd) + .plus(targetNetworkFeeUsd) + .plus(amountUsd) + .toString(10); + + const estimatedDuration = Number( + sumProperty(quotes, (quote) => quote.estimatedDuration), + ); + + return { + estimatedDuration, + fees: { + provider: { + fiat: providerFeeFiat, + usd: providerFeeUsd, + }, + sourceNetwork: { + fiat: sourceNetworkFeeFiat, + usd: sourceNetworkFeeUsd, + }, + targetNetwork: { + fiat: targetNetworkFeeFiat, + usd: targetNetworkFeeUsd, + }, + }, + total: { + fiat: totalFiat, + usd: totalUsd, + }, + }; +} + +/** + * Sum a specific property from a list of items. + * + * @param data - List of items. + * @param getProperty - Function to extract the property to sum from each item. + * @returns The summed value as a string. + */ +function sumProperty( + data: T[], + getProperty: (item: T) => BigNumber.Value, +): string { + return data + .map(getProperty) + .reduce((total, value) => total.plus(value), new BigNumber(0)) + .toString(10); +} diff --git a/packages/transaction-pay-controller/src/utils/transaction.test.ts b/packages/transaction-pay-controller/src/utils/transaction.test.ts new file mode 100644 index 00000000000..a361e0f2ae9 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction.test.ts @@ -0,0 +1,277 @@ +import { Messenger } from '@metamask/base-controller'; +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import type { TransactionControllerState } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { calculateFiat } from './required-fiat'; +import { parseRequiredTokens } from './required-tokens'; +import { + getTransaction, + pollTransactionChanges, + updateTransaction, + waitForTransactionConfirmed, +} from './transaction'; +import type { + TransactionPayControllerMessenger, + TransactionPayPublishHook, +} from '..'; +import type { + AllowedActions, + AllowedEvents, + TransactionData, + TransactionTokenRequired, +} from '../types'; +import { noop } from 'lodash'; + +jest.mock('./required-fiat'); +jest.mock('./required-tokens'); + +const TRANSACTION_ID_MOCK = '123-456'; + +const TRANSACTION_META_MOCK = { + id: TRANSACTION_ID_MOCK, + txParams: { + from: '0x123', + }, +} as TransactionMeta; + +const TRANSCTION_TOKEN_REQUIRED_MOCK = { + address: '0x456' as Hex, +} as TransactionTokenRequired; + +const FIAT_MOCK = { + amountFiat: '2', + amountUsd: '3', + balanceFiat: '4', + balanceUsd: '5', +}; + +describe('Transaction Utils', () => { + let baseMessenger: Messenger; + let messenger: TransactionPayControllerMessenger; + + const getTransactionControllerStateMock = jest.fn(); + const parseRequiredTokensMock = jest.mocked(parseRequiredTokens); + const calculateFiatMock = jest.mocked(calculateFiat); + const updateTransactionActionMock = jest.fn(); + + beforeEach(() => { + jest.resetAllMocks(); + + baseMessenger = new Messenger(); + + baseMessenger.registerActionHandler( + 'TransactionController:getState', + getTransactionControllerStateMock, + ); + + baseMessenger.registerActionHandler( + 'TransactionController:updateTransaction', + updateTransactionActionMock, + ); + + messenger = baseMessenger.getRestricted({ + name: 'TransactionPayController', + allowedActions: [ + 'TransactionController:getState', + 'TransactionController:updateTransaction', + ], + allowedEvents: ['TransactionController:stateChange'], + }); + }); + + describe('getTransaction', () => { + it('returns transaction', () => { + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + }); + + const result = getTransaction(TRANSACTION_ID_MOCK, messenger); + expect(result).toBe(TRANSACTION_META_MOCK); + }); + + it('returns undefined if transaction not found', () => { + getTransactionControllerStateMock.mockReturnValue({ + transactions: [], + }); + + const result = getTransaction(TRANSACTION_ID_MOCK, messenger); + expect(result).toBeUndefined(); + }); + }); + + describe('pollTransactionChanges', () => { + it('updates state for new transactions', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + calculateFiatMock.mockReturnValue(FIAT_MOCK); + + pollTransactionChanges(messenger, updateTransactionDataMock); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + + const transactionData = {} as TransactionData; + updateTransactionDataMock.mock.calls[0][1](transactionData); + + expect(transactionData.tokens).toStrictEqual([ + { + ...TRANSCTION_TOKEN_REQUIRED_MOCK, + ...FIAT_MOCK, + }, + ]); + }); + + it('updates state for updated transactions', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + calculateFiatMock.mockReturnValue(FIAT_MOCK); + + pollTransactionChanges(messenger, updateTransactionDataMock); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [ + { ...TRANSACTION_META_MOCK, txParams: { data: '0x1' } }, + ], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(2); + }); + + it('returns empty array if cannot calculate fiat', () => { + const updateTransactionDataMock = jest.fn(); + + parseRequiredTokensMock.mockReturnValue([TRANSCTION_TOKEN_REQUIRED_MOCK]); + calculateFiatMock.mockReturnValue(undefined); + + pollTransactionChanges(messenger, updateTransactionDataMock); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [TRANSACTION_META_MOCK], + } as TransactionControllerState, + [], + ); + + expect(updateTransactionDataMock).toHaveBeenCalledTimes(1); + + const transactionData = {} as TransactionData; + updateTransactionDataMock.mock.calls[0][1](transactionData); + + expect(transactionData.tokens).toStrictEqual([]); + }); + }); + + describe('updateTransaction', () => { + it('updates transaction', () => { + getTransactionControllerStateMock.mockReturnValue({ + transactions: [TRANSACTION_META_MOCK], + }); + + updateTransaction( + { + transactionId: TRANSACTION_ID_MOCK, + messenger: messenger as never, + note: 'Test note', + }, + (draft) => { + draft.txParams.from = '0x456'; + }, + ); + + expect(updateTransactionActionMock).toHaveBeenCalledWith( + expect.objectContaining({ + id: TRANSACTION_ID_MOCK, + txParams: expect.objectContaining({ + from: '0x456', + }), + }), + 'Test note', + ); + }); + + it('throws if transaction not found', () => { + getTransactionControllerStateMock.mockReturnValue({ + transactions: [], + }); + + expect(() => + updateTransaction( + { + transactionId: TRANSACTION_ID_MOCK, + messenger: messenger as never, + note: 'Test note', + }, + noop, + ), + ).toThrow(`Transaction not found: ${TRANSACTION_ID_MOCK}`); + }); + }); + + describe('waitForTransactionConfirmed', () => { + it('resolves when transaction is confirmed', async () => { + const promise = waitForTransactionConfirmed( + TRANSACTION_ID_MOCK, + messenger as never, + ); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [ + { ...TRANSACTION_META_MOCK, status: TransactionStatus.confirmed }, + ], + } as TransactionControllerState, + [], + ); + + expect(await promise).toBeUndefined(); + }); + + it('rejects when transaction fails', async () => { + const promise = waitForTransactionConfirmed( + TRANSACTION_ID_MOCK, + messenger as never, + ); + + baseMessenger.publish( + 'TransactionController:stateChange', + { + transactions: [ + { ...TRANSACTION_META_MOCK, status: TransactionStatus.failed }, + ], + } as TransactionControllerState, + [], + ); + + await expect(promise).rejects.toThrow( + `Transaction status is ${TransactionStatus.failed}`, + ); + }); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/transaction.ts b/packages/transaction-pay-controller/src/utils/transaction.ts new file mode 100644 index 00000000000..960bd01efe9 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/transaction.ts @@ -0,0 +1,201 @@ +import { + TransactionStatus, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { createModuleLogger } from '@metamask/utils'; +import { cloneDeep } from 'lodash'; + +import { calculateFiat } from './required-fiat'; +import { parseRequiredTokens } from './required-tokens'; +import { projectLogger } from '../logger'; +import type { + TransactionPayControllerMessenger, + TransactionPayPublishHookMessenger, + TransactionToken, + TransactionTokenFiat, + UpdateTransactionDataCallback, +} from '../types'; + +const log = createModuleLogger(projectLogger, 'transaction'); + +/** + * Retrieve transaction metadata by ID. + * + * @param transactionId - ID of the transaction to retrieve. + * @param messenger - Controller messenger. + * @returns The transaction metadata or undefined if not found. + */ +export function getTransaction( + transactionId: string, + messenger: TransactionPayControllerMessenger, +): TransactionMeta | undefined { + const transactionControllerState = messenger.call( + 'TransactionController:getState', + ); + + return transactionControllerState.transactions.find( + (tx) => tx.id === transactionId, + ); +} + +/** + * Poll for transaction changes and update the transaction data accordingly. + * + * @param messenger - Controller messenger. + * @param updateTransactionData - Callback to update transaction data. + */ +export function pollTransactionChanges( + messenger: TransactionPayControllerMessenger, + updateTransactionData: UpdateTransactionDataCallback, +) { + messenger.subscribe( + 'TransactionController:stateChange', + ( + transactions: TransactionMeta[], + previousTransactions: TransactionMeta[] | undefined, + ) => { + const newTransactions = transactions.filter( + (tx) => !previousTransactions?.find((prevTx) => prevTx.id === tx.id), + ); + + const updatedTransactions = transactions.filter((tx) => { + const previousTransaction = previousTransactions?.find( + (prevTx) => prevTx.id === tx.id, + ); + + return ( + previousTransaction && + previousTransaction?.txParams.data !== tx.txParams.data + ); + }); + + [...newTransactions, ...updatedTransactions].forEach((tx) => + onTransactionChange(tx, messenger, updateTransactionData), + ); + }, + (state) => state.transactions, + ); +} + +/** + * Wait for a transaction to be confirmed or fail. + * + * @param transactionId - ID of the transaction to wait for. + * @param messenger - Controller messenger. + * @returns A promise that resolves when the transaction is confirmed or rejects if it fails. + */ +export function waitForTransactionConfirmed( + transactionId: string, + messenger: TransactionPayPublishHookMessenger, +) { + return new Promise((resolve, reject) => { + const handler = (transactionMeta: TransactionMeta | undefined) => { + const unsubscribe = () => + messenger.unsubscribe('TransactionController:stateChange', handler); + + if (transactionMeta?.status === TransactionStatus.confirmed) { + unsubscribe(); + resolve(); + } + + if ( + [TransactionStatus.failed, TransactionStatus.dropped].includes( + transactionMeta?.status as TransactionStatus, + ) + ) { + unsubscribe(); + reject(new Error(`Transaction status is ${transactionMeta?.status}`)); + } + }; + + messenger.subscribe('TransactionController:stateChange', handler, (state) => + state.transactions.find((tx) => tx.id === transactionId), + ); + }); +} + +/** + * Update a transaction by applying a function to its draft. + * + * @param request - Request object. + * @param request.transactionId - ID of the transaction to update. + * @param request.messenger - Controller messenger. + * @param request.note - Note describing the update. + * @param fn - Function that applies updates to the transaction draft. + */ +export function updateTransaction( + { + transactionId, + messenger, + note, + }: { + transactionId: string; + messenger: TransactionPayPublishHookMessenger; + note: string; + }, + fn: (draft: TransactionMeta) => void, +) { + const transaction = getTransaction(transactionId, messenger as never); + + if (!transaction) { + throw new Error(`Transaction not found: ${transactionId}`); + } + + const newTransaction = cloneDeep(transaction); + + fn(newTransaction); + + messenger.call( + 'TransactionController:updateTransaction', + newTransaction, + note, + ); +} + +/** + * Handle a transaction change by updating its associated data. + * + * @param transaction - Transaction metadata. + * @param messenger - Controller messenger. + * @param updateTransactionData - Callback to update transaction data. + */ +function onTransactionChange( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, + updateTransactionData: UpdateTransactionDataCallback, +) { + const tokens = getTokens(transaction, messenger); + + log('Transaction changed', { transaction, tokens }); + + updateTransactionData(transaction.id, (data) => { + data.tokens = tokens; + }); +} + +/** + * Generate the token data for a transaction. + * + * @param transaction - Transaction metadata. + * @param messenger - Controller messenger. + * @returns An array of transaction tokens. + */ +function getTokens( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): TransactionToken[] { + const requiredTokens = parseRequiredTokens(transaction, messenger); + + const fiat = requiredTokens + .map((t) => calculateFiat(t, messenger)) + .filter(Boolean); + + if (requiredTokens.length !== fiat.length) { + return []; + } + + return requiredTokens.map((t, i) => ({ + ...t, + ...(fiat[i] as TransactionTokenFiat), + })); +} diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json new file mode 100644 index 00000000000..f62f69e928f --- /dev/null +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -0,0 +1,35 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../assets-controllers/tsconfig.build.json" + }, + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../bridge-controller/tsconfig.build.json" + }, + { + "path": "../bridge-status-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + }, + { + "path": "../network-controller/tsconfig.build.json" + }, + { + "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json new file mode 100644 index 00000000000..7390a7bbb0c --- /dev/null +++ b/packages/transaction-pay-controller/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../assets-controllers" + }, + { + "path": "../base-controller" + }, + { + "path": "../bridge-controller" + }, + { + "path": "../bridge-status-controller" + }, + { + "path": "../controller-utils" + }, + { + "path": "../network-controller" + }, + { + "path": "../remote-feature-flag-controller" + }, + { + "path": "../transaction-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/transaction-pay-controller/typedoc.json b/packages/transaction-pay-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/transaction-pay-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 15b053fb6bc..a6a73c13bb6 100644 --- a/teams.json +++ b/teams.json @@ -46,6 +46,7 @@ "metamask/selected-network-controller": "team-wallet-api-platform,team-wallet-framework,team-assets", "metamask/signature-controller": "team-confirmations", "metamask/transaction-controller": "team-confirmations", + "metamask/transaction-pay-controller": "team-confirmations", "metamask/user-operation-controller": "team-confirmations", "metamask/multichain-transactions-controller": "team-new-networks,team-accounts", "metamask/token-search-discovery-controller": "team-portfolio", diff --git a/tsconfig.build.json b/tsconfig.build.json index 712eded094a..d3488070a57 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -61,6 +61,7 @@ "path": "./packages/token-search-discovery-controller/tsconfig.build.json" }, { "path": "./packages/transaction-controller/tsconfig.build.json" }, + { "path": "./packages/transaction-pay-controller/tsconfig.build.json" }, { "path": "./packages/user-operation-controller/tsconfig.build.json" } ], "files": [], diff --git a/tsconfig.json b/tsconfig.json index 12cd1a24324..285999d4592 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,7 @@ { "path": "./packages/subscription-controller" }, { "path": "./packages/token-search-discovery-controller" }, { "path": "./packages/transaction-controller" }, + { "path": "./packages/transaction-pay-controller" }, { "path": "./packages/user-operation-controller" } ], "files": [], diff --git a/yarn.lock b/yarn.lock index 894667ed4d3..1729da86d96 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2774,7 +2774,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^49.0.1, @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: @@ -4821,6 +4821,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^1.8.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.8.1" + "@ts-bridge/cli": "npm:^0.6.1" "@types/bn.js": "npm:^5.1.5" "@types/jest": "npm:^27.4.1" "@types/node": "npm:^16.18.54" @@ -4850,6 +4851,45 @@ __metadata: languageName: unknown linkType: soft +"@metamask/transaction-pay-controller@workspace:packages/transaction-pay-controller": + version: 0.0.0-use.local + resolution: "@metamask/transaction-pay-controller@workspace:packages/transaction-pay-controller" + dependencies: + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@metamask/assets-controllers": "npm:^79.0.1" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^8.4.1" + "@metamask/bridge-controller": "npm:^49.0.1" + "@metamask/bridge-status-controller": "npm:^49.0.1" + "@metamask/controller-utils": "npm:^11.14.1" + "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/network-controller": "npm:^24.2.1" + "@metamask/remote-feature-flag-controller": "npm:^1.8.0" + "@metamask/transaction-controller": "npm:^60.6.1" + "@metamask/utils": "npm:^11.8.1" + "@ts-bridge/cli": "npm:^0.6.1" + "@types/jest": "npm:^27.4.1" + bignumber.js: "npm:^9.1.2" + bn.js: "npm:^5.2.1" + deepmerge: "npm:^4.2.2" + immer: "npm:^9.0.6" + jest: "npm:^27.5.1" + lodash: "npm:^4.17.21" + 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/assets-controllers": ^79.0.0 + "@metamask/bridge-controller": ^49.0.0 + "@metamask/bridge-status-controller": ^49.0.0 + "@metamask/network-controller": ^24.0.0 + "@metamask/remote-feature-flag-controller": ^1.5.0 + "@metamask/transaction-controller": ^60.0.0 + languageName: unknown + linkType: soft + "@metamask/user-operation-controller@workspace:packages/user-operation-controller": version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller"