diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 049a35be34..3f5129d1f8 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -49,6 +49,7 @@ /packages/money-account-balance-service @MetaMask/earn /packages/chomp-api-service @MetaMask/earn @MetaMask/delegation /packages/money-account-upgrade-controller @MetaMask/earn @MetaMask/delegation +/packages/money-account-utils @MetaMask/earn ## Social AI Team /packages/ai-controllers @MetaMask/social-ai @@ -271,5 +272,7 @@ /packages/chomp-api-service/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-utils/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-utils/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform /packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform diff --git a/README.md b/README.md index 80a1a9a225..96f41dfc80 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) - [`@metamask/money-account-upgrade-controller`](packages/money-account-upgrade-controller) +- [`@metamask/money-account-utils`](packages/money-account-utils) - [`@metamask/multichain-account-service`](packages/multichain-account-service) - [`@metamask/multichain-api-middleware`](packages/multichain-api-middleware) - [`@metamask/multichain-network-controller`](packages/multichain-network-controller) @@ -196,6 +197,7 @@ linkStyle default opacity:0.5 money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); money_account_upgrade_controller(["@metamask/money-account-upgrade-controller"]); + money_account_utils(["@metamask/money-account-utils"]); multichain_account_service(["@metamask/multichain-account-service"]); multichain_api_middleware(["@metamask/multichain-api-middleware"]); multichain_network_controller(["@metamask/multichain-network-controller"]); @@ -427,6 +429,7 @@ linkStyle default opacity:0.5 money_account_upgrade_controller --> keyring_controller; money_account_upgrade_controller --> messenger; money_account_upgrade_controller --> network_controller; + money_account_utils --> transaction_controller; multichain_account_service --> accounts_controller; multichain_account_service --> base_controller; multichain_account_service --> keyring_controller; diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md new file mode 100644 index 0000000000..87eb3fcf35 --- /dev/null +++ b/packages/money-account-utils/CHANGELOG.md @@ -0,0 +1,19 @@ +# 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 + +- Add mUSD token constants and guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Constants: `MUSD_TOKEN` (without client icon assets), `MUSD_DECIMALS`, `MUSD_TOKEN_ADDRESS`, `MUSD_TOKEN_ADDRESS_BY_CHAIN`, `MUSD_TOKEN_ASSET_ID_BY_CHAIN`, `MUSD_CURRENCY`, `MUSD_MONEY_ACCOUNT_CHAIN_IDS` + - Guards: `isMusdToken`, `isMusdTokenOnChain`, `isMusdOnMoneyAccountChain` +- Add Money Account transaction guards, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Guards over `TransactionMeta`: `nestedTxWithType`, `isMoneyDepositTx`, `isMoneyWithdrawTx`, `isMoneyAccountTx`, `isSingleRowMusdMoneyWithdraw`, `isPerpsPredictMoneyDeposit`, `isPerpsPredictMoneyWithdraw`, `isPerpsPredictMoneyActivity`, `perpsPredictServiceFamily` + - MetaMask Pay helpers: `getMMPayChainIds`, `PERPS_PREDICT_DEPOSIT_TYPES`, `PERPS_PREDICT_WITHDRAW_TYPES` + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-utils/LICENSE b/packages/money-account-utils/LICENSE new file mode 100644 index 0000000000..9ec4f4514e --- /dev/null +++ b/packages/money-account-utils/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/money-account-utils/LICENSE.APACHE2 b/packages/money-account-utils/LICENSE.APACHE2 new file mode 100644 index 0000000000..e6e77b0890 --- /dev/null +++ b/packages/money-account-utils/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/money-account-utils/LICENSE.MIT b/packages/money-account-utils/LICENSE.MIT new file mode 100644 index 0000000000..fe29e78e0f --- /dev/null +++ b/packages/money-account-utils/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 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 +SOFTWARE. diff --git a/packages/money-account-utils/README.md b/packages/money-account-utils/README.md new file mode 100644 index 0000000000..98244e8904 --- /dev/null +++ b/packages/money-account-utils/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-utils` + +Shared money account utilities: activity parsing, classification, and mUSD constants. + +## Installation + +`yarn add @metamask/money-account-utils` + +or + +`npm install @metamask/money-account-utils` + +## 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/money-account-utils/jest.config.js b/packages/money-account-utils/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/money-account-utils/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/money-account-utils/package.json b/packages/money-account-utils/package.json new file mode 100644 index 0000000000..41a748569b --- /dev/null +++ b/packages/money-account-utils/package.json @@ -0,0 +1,74 @@ +{ + "name": "@metamask/money-account-utils", + "version": "0.0.0", + "description": "Shared money account utilities: activity parsing, classification, and mUSD constants", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-utils#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "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" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-utils", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-utils", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/transaction-controller": "^68.2.2", + "@metamask/utils": "^11.11.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts new file mode 100644 index 0000000000..52f76b5498 --- /dev/null +++ b/packages/money-account-utils/src/index.ts @@ -0,0 +1,26 @@ +export { + PERPS_PREDICT_DEPOSIT_TYPES, + PERPS_PREDICT_WITHDRAW_TYPES, + nestedTxWithType, + isMoneyDepositTx, + isMoneyWithdrawTx, + isMoneyAccountTx, + isSingleRowMusdMoneyWithdraw, + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, + isPerpsPredictMoneyActivity, + perpsPredictServiceFamily, + getMMPayChainIds, +} from './moneyTransactionGuards'; +export { + MUSD_TOKEN, + MUSD_DECIMALS, + MUSD_TOKEN_ADDRESS, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, + MUSD_CURRENCY, + MUSD_MONEY_ACCOUNT_CHAIN_IDS, + isMusdToken, + isMusdTokenOnChain, + isMusdOnMoneyAccountChain, +} from './musd'; diff --git a/packages/money-account-utils/src/moneyTransactionGuards.test.ts b/packages/money-account-utils/src/moneyTransactionGuards.test.ts new file mode 100644 index 0000000000..41ba26c058 --- /dev/null +++ b/packages/money-account-utils/src/moneyTransactionGuards.test.ts @@ -0,0 +1,404 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + CHAIN_IDS, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { + getMMPayChainIds, + isMoneyAccountTx, + isMoneyDepositTx, + isMoneyWithdrawTx, + isPerpsPredictMoneyActivity, + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, + isSingleRowMusdMoneyWithdraw, + nestedTxWithType, + perpsPredictServiceFamily, +} from './moneyTransactionGuards'; +import { MUSD_TOKEN_ADDRESS } from './musd'; + +const baseTx = { + id: 'tx-1', + status: TransactionStatus.confirmed, + time: 0, + txParams: {}, +} as unknown as TransactionMeta; + +const makeTx = ( + type: TransactionType, + nested?: { type: TransactionType }[], +): TransactionMeta => + ({ + ...baseTx, + type, + nestedTransactions: nested, + }) as unknown as TransactionMeta; + +describe('nestedTxWithType', () => { + it('returns nested tx matching the target type', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountDeposit }, + ]); + const result = nestedTxWithType(tx, TransactionType.moneyAccountDeposit); + expect(result?.type).toBe(TransactionType.moneyAccountDeposit); + }); + + it('returns undefined when no nested tx matches', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountWithdraw }, + ]); + expect( + nestedTxWithType(tx, TransactionType.moneyAccountDeposit), + ).toBeUndefined(); + }); + + it('returns undefined when nestedTransactions is absent', () => { + const tx = makeTx(TransactionType.contractInteraction); + expect( + nestedTxWithType(tx, TransactionType.moneyAccountDeposit), + ).toBeUndefined(); + }); +}); + +describe('isMoneyDepositTx', () => { + it('returns true for top-level deposit tx', () => { + expect(isMoneyDepositTx(makeTx(TransactionType.moneyAccountDeposit))).toBe( + true, + ); + }); + + it('returns true for tx with nested deposit', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountDeposit }, + ]); + expect(isMoneyDepositTx(tx)).toBe(true); + }); + + it('returns false for withdraw tx', () => { + expect(isMoneyDepositTx(makeTx(TransactionType.moneyAccountWithdraw))).toBe( + false, + ); + }); + + it('returns false for unrelated tx type', () => { + expect(isMoneyDepositTx(makeTx(TransactionType.contractInteraction))).toBe( + false, + ); + }); +}); + +describe('isMoneyWithdrawTx', () => { + it('returns true for top-level withdraw tx', () => { + expect( + isMoneyWithdrawTx(makeTx(TransactionType.moneyAccountWithdraw)), + ).toBe(true); + }); + + it('returns true for tx with nested withdraw', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountWithdraw }, + ]); + expect(isMoneyWithdrawTx(tx)).toBe(true); + }); + + it('returns false for deposit tx', () => { + expect(isMoneyWithdrawTx(makeTx(TransactionType.moneyAccountDeposit))).toBe( + false, + ); + }); + + it('returns false for unrelated tx type', () => { + expect(isMoneyWithdrawTx(makeTx(TransactionType.contractInteraction))).toBe( + false, + ); + }); +}); + +describe('isSingleRowMusdMoneyWithdraw', () => { + it('returns true when destination is mUSD on Monad', () => { + expect( + isSingleRowMusdMoneyWithdraw({ + ...makeTx(TransactionType.moneyAccountWithdraw), + metamaskPay: { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }, + } as TransactionMeta), + ).toBe(true); + }); + + it('returns true when destination is mUSD on another chain (cross-chain)', () => { + expect( + isSingleRowMusdMoneyWithdraw({ + ...makeTx(TransactionType.moneyAccountWithdraw), + metamaskPay: { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.LINEA_MAINNET, + }, + } as TransactionMeta), + ).toBe(true); + }); + + it('returns false when destination is a non-mUSD token', () => { + expect( + isSingleRowMusdMoneyWithdraw({ + ...makeTx(TransactionType.moneyAccountWithdraw), + metamaskPay: { + tokenAddress: '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef', + chainId: CHAIN_IDS.LINEA_MAINNET, + }, + } as TransactionMeta), + ).toBe(false); + }); + + it('returns false for deposit txs', () => { + expect( + isSingleRowMusdMoneyWithdraw({ + ...makeTx(TransactionType.moneyAccountDeposit), + metamaskPay: { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }, + } as TransactionMeta), + ).toBe(false); + }); +}); + +describe('isMoneyAccountTx', () => { + it('returns true for deposit', () => { + expect(isMoneyAccountTx(makeTx(TransactionType.moneyAccountDeposit))).toBe( + true, + ); + }); + + it('returns true for withdraw', () => { + expect(isMoneyAccountTx(makeTx(TransactionType.moneyAccountWithdraw))).toBe( + true, + ); + }); + + it('returns true for tx with nested deposit', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountDeposit }, + ]); + expect(isMoneyAccountTx(tx)).toBe(true); + }); + + it('returns true for tx with nested withdraw', () => { + const tx = makeTx(TransactionType.contractInteraction, [ + { type: TransactionType.moneyAccountWithdraw }, + ]); + expect(isMoneyAccountTx(tx)).toBe(true); + }); + + it('returns false for unrelated tx type', () => { + expect(isMoneyAccountTx(makeTx(TransactionType.contractInteraction))).toBe( + false, + ); + }); +}); + +// mUSD on Monad as a MetaMask Pay token = funded by / landing in the Money +// account; any other token/chain is unrelated to it. +const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD as Hex, +}; +const USDC_ON_ARBITRUM = { + tokenAddress: '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' as Hex, + chainId: CHAIN_IDS.ARBITRUM as Hex, +}; + +const makeServiceTx = ( + type: TransactionType | undefined, + metamaskPay: Record | undefined, + nested?: { type: TransactionType }[], +): TransactionMeta => + ({ + ...baseTx, + type, + metamaskPay, + nestedTransactions: nested, + }) as unknown as TransactionMeta; + +describe('isPerpsPredictMoneyDeposit', () => { + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])('returns true for %s paid with mUSD on Monad', (type) => { + expect(isPerpsPredictMoneyDeposit(makeServiceTx(type, MUSD_ON_MONAD))).toBe( + true, + ); + }); + + it('returns false when the pay token is not mUSD on the Money chain', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsDeposit, USDC_ON_ARBITRUM), + ), + ).toBe(false); + }); + + it('returns false when there is no metamaskPay (not money-funded)', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsDeposit, undefined), + ), + ).toBe(false); + }); + + it('returns false for a withdraw type even with an mUSD pay token', () => { + expect( + isPerpsPredictMoneyDeposit( + makeServiceTx(TransactionType.perpsWithdraw, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('isPerpsPredictMoneyWithdraw', () => { + it('returns true for a top-level withdraw landing as mUSD on Monad', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.perpsWithdraw, MUSD_ON_MONAD), + ), + ).toBe(true); + }); + + it('returns true for an EIP-7702 batch with a nested predictWithdraw', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe(true); + }); + + it('returns false when the destination is not mUSD on the Money chain', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.predictWithdraw, USDC_ON_ARBITRUM), + ), + ).toBe(false); + }); + + it('returns false for a deposit type', () => { + expect( + isPerpsPredictMoneyWithdraw( + makeServiceTx(TransactionType.perpsDeposit, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('isPerpsPredictMoneyActivity', () => { + it('returns true for both a money-funded deposit and an mUSD withdraw', () => { + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.perpsDeposit, MUSD_ON_MONAD), + ), + ).toBe(true); + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe(true); + }); + + it('returns false for an unrelated tx', () => { + expect( + isPerpsPredictMoneyActivity( + makeServiceTx(TransactionType.contractInteraction, MUSD_ON_MONAD), + ), + ).toBe(false); + }); +}); + +describe('perpsPredictServiceFamily', () => { + it.each([ + [TransactionType.perpsDeposit, 'perps'], + [TransactionType.perpsDepositAndOrder, 'perps'], + [TransactionType.perpsWithdraw, 'perps'], + [TransactionType.predictDeposit, 'predict'], + [TransactionType.predictDepositAndOrder, 'predict'], + [TransactionType.predictWithdraw, 'predict'], + ])('maps %s to %s', (type, family) => { + expect(perpsPredictServiceFamily(makeServiceTx(type, MUSD_ON_MONAD))).toBe( + family, + ); + }); + + it('resolves the family from a nested batch call', () => { + expect( + perpsPredictServiceFamily( + makeServiceTx(TransactionType.batch, MUSD_ON_MONAD, [ + { type: TransactionType.predictWithdraw }, + ]), + ), + ).toBe('predict'); + }); + + it('returns undefined for a non-service tx', () => { + expect( + perpsPredictServiceFamily( + makeServiceTx(TransactionType.moneyAccountWithdraw, undefined), + ), + ).toBeUndefined(); + }); +}); + +describe('getMMPayChainIds', () => { + const makePayTx = ( + chainId: `0x${string}`, + payChainId: `0x${string}` | undefined, + isPostQuote: boolean, + ): TransactionMeta => + ({ + ...baseTx, + chainId, + type: TransactionType.moneyAccountDeposit, + metamaskPay: + payChainId === undefined + ? undefined + : { chainId: payChainId, isPostQuote }, + }) as unknown as TransactionMeta; + + it('returns pay chainId as source and local as destination when isPostQuote is false', () => { + const tx = makePayTx('0x1', '0x89', false); + + const result = getMMPayChainIds(tx); + + expect(result.sourceChainId).toBe('0x89'); + expect(result.destinationChainId).toBe('0x1'); + }); + + it('returns local chainId as source and pay chainId as destination when isPostQuote is true', () => { + const tx = makePayTx('0x1', '0x89', true); + + const result = getMMPayChainIds(tx); + + expect(result.sourceChainId).toBe('0x1'); + expect(result.destinationChainId).toBe('0x89'); + }); + + it('falls back to the local chainId for both when metamaskPay is absent', () => { + const tx = { + ...baseTx, + chainId: '0x1', + type: TransactionType.contractInteraction, + } as unknown as TransactionMeta; + + const result = getMMPayChainIds(tx); + + expect(result.sourceChainId).toBe(tx.chainId); + expect(result.destinationChainId).toBe(tx.chainId); + }); +}); diff --git a/packages/money-account-utils/src/moneyTransactionGuards.ts b/packages/money-account-utils/src/moneyTransactionGuards.ts new file mode 100644 index 0000000000..eba8be91f1 --- /dev/null +++ b/packages/money-account-utils/src/moneyTransactionGuards.ts @@ -0,0 +1,252 @@ +import type { + NestedTransactionMetadata, + TransactionMeta, +} from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { isMusdOnMoneyAccountChain, isMusdToken } from './musd'; + +/** + * Find the first nested transaction matching a given {@link TransactionType}. + * + * @param transactionMeta - The transaction to search. + * @param targetType - The nested transaction type to look for. + * @returns The first matching nested transaction, or undefined if none exists. + */ +export function nestedTxWithType( + transactionMeta: TransactionMeta, + targetType: TransactionType, +): NestedTransactionMetadata | undefined { + return transactionMeta.nestedTransactions?.find( + (nested) => nested.type === targetType, + ); +} + +/** + * Check whether a transaction is a Money Account deposit, either as its + * top-level type or nested inside a batch. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction is a Money Account deposit. + */ +export function isMoneyDepositTx(transactionMeta: TransactionMeta): boolean { + return ( + transactionMeta.type === TransactionType.moneyAccountDeposit || + Boolean( + nestedTxWithType(transactionMeta, TransactionType.moneyAccountDeposit), + ) + ); +} + +/** + * Check whether a transaction is a Money Account withdrawal, either as its + * top-level type or nested inside a batch. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction is a Money Account withdrawal. + */ +export function isMoneyWithdrawTx(transactionMeta: TransactionMeta): boolean { + return ( + transactionMeta.type === TransactionType.moneyAccountWithdraw || + Boolean( + nestedTxWithType(transactionMeta, TransactionType.moneyAccountWithdraw), + ) + ); +} + +/** + * Check whether a Money Account withdrawal lands as mUSD (single-row hero and + * "Sent mUSD" title). Cross-token destinations (e.g. USDC) return false. + * Chain is irrelevant — only the destination token matters. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction is an mUSD-destination Money withdrawal. + */ +export function isSingleRowMusdMoneyWithdraw( + transactionMeta: TransactionMeta, +): boolean { + if (!isMoneyWithdrawTx(transactionMeta)) { + return false; + } + return isMusdToken(transactionMeta.metamaskPay?.tokenAddress); +} + +/** + * Check whether a transaction is a Money Account deposit or withdrawal. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction touches the Money Account. + */ +export function isMoneyAccountTx(transactionMeta: TransactionMeta): boolean { + return ( + isMoneyDepositTx(transactionMeta) || isMoneyWithdrawTx(transactionMeta) + ); +} + +/** + * Perps/Predict deposit parent types (money → service). When funded from the + * Money account these are paid with mUSD via MetaMask Pay; the on-chain deposit + * is signed from the user's EOA on the service chain (Arbitrum/Polygon). + */ +export const PERPS_PREDICT_DEPOSIT_TYPES: TransactionType[] = [ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, +]; + +/** + * Perps/Predict withdraw types (service → money). When the destination is the + * Money account these arrive as mUSD on Monad. The withdraw is wrapped in an + * EIP-7702 `batch`, so the type sits in `nestedTransactions`. + */ +export const PERPS_PREDICT_WITHDRAW_TYPES: TransactionType[] = [ + TransactionType.perpsWithdraw, + TransactionType.predictWithdraw, +]; + +/** + * Resolve the Perps/Predict deposit or withdraw type for a transaction, + * unwrapping an EIP-7702 `batch` whose money-moving call sits in + * `nestedTransactions`. + * + * @param transactionMeta - The transaction to inspect. + * @returns The service transaction type, or undefined for other transactions. + */ +function effectiveServiceType( + transactionMeta: TransactionMeta, +): TransactionType | undefined { + const serviceTypes = [ + ...PERPS_PREDICT_DEPOSIT_TYPES, + ...PERPS_PREDICT_WITHDRAW_TYPES, + ]; + if (transactionMeta.type && serviceTypes.includes(transactionMeta.type)) { + return transactionMeta.type; + } + return transactionMeta.nestedTransactions?.find( + (nested) => nested.type && serviceTypes.includes(nested.type), + )?.type; +} + +/** + * Check whether the `metamaskPay` token is mUSD on the Money account chain + * (Monad). For a deposit this is the source the Money account paid; for a + * withdraw (`isPostQuote`) it's the destination — either way it links the + * transaction to the Money account. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the pay token is mUSD on the Money account chain. + */ +function isMusdMoneyPayToken(transactionMeta: TransactionMeta): boolean { + return isMusdOnMoneyAccountChain( + transactionMeta.metamaskPay?.tokenAddress, + transactionMeta.metamaskPay?.chainId, + ); +} + +/** + * Check for a Perps/Predict deposit funded from the Money account — from the + * perspective of the Money account this is a 'Send'. The transaction `from` is + * the user's EOA, not the Money account, so it's matched via the mUSD pay + * token rather than the address. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction is a Money-funded Perps/Predict deposit. + */ +export function isPerpsPredictMoneyDeposit( + transactionMeta: TransactionMeta, +): boolean { + const type = effectiveServiceType(transactionMeta); + return ( + Boolean(type) && + PERPS_PREDICT_DEPOSIT_TYPES.includes(type as TransactionType) && + isMusdMoneyPayToken(transactionMeta) + ); +} + +/** + * Check for a Perps/Predict withdraw landing in the Money account — an inflow + * ("Deposited"). + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction is a Perps/Predict withdraw into the Money + * account. + */ +export function isPerpsPredictMoneyWithdraw( + transactionMeta: TransactionMeta, +): boolean { + const type = effectiveServiceType(transactionMeta); + return ( + Boolean(type) && + PERPS_PREDICT_WITHDRAW_TYPES.includes(type as TransactionType) && + isMusdMoneyPayToken(transactionMeta) + ); +} + +/** + * Check whether a transaction is a Perps/Predict ↔ Money transfer in either + * direction. + * + * @param transactionMeta - The transaction to check. + * @returns Whether the transaction moves funds between the Money account and + * a Perps/Predict service. + */ +export function isPerpsPredictMoneyActivity( + transactionMeta: TransactionMeta, +): boolean { + return ( + isPerpsPredictMoneyDeposit(transactionMeta) || + isPerpsPredictMoneyWithdraw(transactionMeta) + ); +} + +/** + * Resolve the service family for a Perps/Predict ↔ Money transaction, used to + * label the activity row's subtitle. + * + * @param transactionMeta - The transaction to inspect. + * @returns 'perps' or 'predict' for service transactions, undefined otherwise. + */ +export function perpsPredictServiceFamily( + transactionMeta: TransactionMeta, +): 'perps' | 'predict' | undefined { + const type = effectiveServiceType(transactionMeta); + if ( + type === TransactionType.perpsDeposit || + type === TransactionType.perpsDepositAndOrder || + type === TransactionType.perpsWithdraw + ) { + return 'perps'; + } + if ( + type === TransactionType.predictDeposit || + type === TransactionType.predictDepositAndOrder || + type === TransactionType.predictWithdraw + ) { + return 'predict'; + } + return undefined; +} + +/** + * Resolve source and destination chain IDs for a MetaMask Pay transaction. + * + * `metamaskPay.chainId` is the payment-token chain. Its role flips based on + * `isPostQuote`: for withdrawals (post-quote) it's the destination, for + * deposits it's the source. + * + * @param transactionMeta - The transaction to inspect. + * @returns The source and destination chain ids of the payment. + */ +export function getMMPayChainIds(transactionMeta: TransactionMeta): { + sourceChainId: Hex | undefined; + destinationChainId: Hex | undefined; +} { + const local = transactionMeta.chainId; + const pay = transactionMeta.metamaskPay?.chainId; + + return transactionMeta.metamaskPay?.isPostQuote + ? { sourceChainId: local, destinationChainId: pay } + : { sourceChainId: pay ?? local, destinationChainId: local }; +} diff --git a/packages/money-account-utils/src/musd.test.ts b/packages/money-account-utils/src/musd.test.ts new file mode 100644 index 0000000000..09edffeb74 --- /dev/null +++ b/packages/money-account-utils/src/musd.test.ts @@ -0,0 +1,132 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; + +import { + isMusdOnMoneyAccountChain, + isMusdToken, + isMusdTokenOnChain, + MUSD_DECIMALS, + MUSD_MONEY_ACCOUNT_CHAIN_IDS, + MUSD_TOKEN, + MUSD_TOKEN_ADDRESS, + MUSD_TOKEN_ADDRESS_BY_CHAIN, + MUSD_TOKEN_ASSET_ID_BY_CHAIN, +} from './musd'; + +const MUSD_ADDRESS = MUSD_TOKEN_ADDRESS_BY_CHAIN[CHAIN_IDS.MAINNET]; + +describe('mUSD constants', () => { + it('derives MUSD_DECIMALS from MUSD_TOKEN', () => { + expect(MUSD_DECIMALS).toBe(MUSD_TOKEN.decimals); + }); + + it('uses the same address on every supported chain', () => { + for (const address of Object.values(MUSD_TOKEN_ADDRESS_BY_CHAIN)) { + expect(address).toBe(MUSD_TOKEN_ADDRESS); + } + }); + + it('defines a CAIP asset id for every chain mUSD is deployed on', () => { + expect(Object.keys(MUSD_TOKEN_ASSET_ID_BY_CHAIN).sort()).toStrictEqual( + Object.keys(MUSD_TOKEN_ADDRESS_BY_CHAIN).sort(), + ); + for (const assetId of Object.values(MUSD_TOKEN_ASSET_ID_BY_CHAIN)) { + expect(assetId.toLowerCase()).toContain( + `erc20:${MUSD_TOKEN_ADDRESS.toLowerCase()}`, + ); + } + }); + + it('only tracks Money Account activity on a subset of deployed chains', () => { + for (const chainId of MUSD_MONEY_ACCOUNT_CHAIN_IDS) { + expect(MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]).toBeDefined(); + } + }); +}); + +describe('isMusdToken', () => { + it('returns true for the mUSD token address in lowercase', () => { + expect(isMusdToken(MUSD_ADDRESS)).toBe(true); + }); + + it('returns true for the mUSD token address in uppercase', () => { + expect(isMusdToken(MUSD_ADDRESS.toUpperCase())).toBe(true); + }); + + it('returns true for the mUSD token address with mixed case', () => { + expect(isMusdToken('0xAcA92E438df0B2401fF60dA7E4337B687a2435DA')).toBe( + true, + ); + }); + + it('returns false for a non-mUSD token address', () => { + expect(isMusdToken('0x1234567890123456789012345678901234567890')).toBe( + false, + ); + }); + + it('returns false for an undefined address', () => { + expect(isMusdToken(undefined)).toBe(false); + }); + + it('returns false for an empty string address', () => { + expect(isMusdToken('')).toBe(false); + }); +}); + +describe('isMusdTokenOnChain', () => { + it('returns true for the mUSD address on a supported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET)).toBe( + true, + ); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(true); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for the mUSD address on an unsupported chain', () => { + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.POLYGON)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.ARBITRUM)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, CHAIN_IDS.OPTIMISM)).toBe(false); + }); + + it('is case-insensitive', () => { + expect( + isMusdTokenOnChain(MUSD_ADDRESS.toUpperCase(), CHAIN_IDS.MAINNET), + ).toBe(true); + }); + + it('returns false for a missing address or chainId', () => { + expect(isMusdTokenOnChain(undefined, CHAIN_IDS.MAINNET)).toBe(false); + expect(isMusdTokenOnChain(MUSD_ADDRESS, undefined)).toBe(false); + }); +}); + +describe('isMusdOnMoneyAccountChain', () => { + it('returns true only for mUSD on Monad', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MONAD)).toBe(true); + }); + + it('returns false for mUSD on chains where mUSD is deployed but the Money Account is not active', () => { + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.MAINNET)).toBe( + false, + ); + expect( + isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.LINEA_MAINNET), + ).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, CHAIN_IDS.BSC)).toBe(false); + }); + + it('returns false for missing arguments', () => { + expect(isMusdOnMoneyAccountChain(undefined, CHAIN_IDS.MONAD)).toBe(false); + expect(isMusdOnMoneyAccountChain(MUSD_ADDRESS, undefined)).toBe(false); + }); + + it('returns false for a non-mUSD address on Monad', () => { + expect( + isMusdOnMoneyAccountChain( + '0x1234567890123456789012345678901234567890', + CHAIN_IDS.MONAD, + ), + ).toBe(false); + }); +}); diff --git a/packages/money-account-utils/src/musd.ts b/packages/money-account-utils/src/musd.ts new file mode 100644 index 0000000000..2248cc1a85 --- /dev/null +++ b/packages/money-account-utils/src/musd.ts @@ -0,0 +1,120 @@ +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +/** + * The mUSD (MetaMask USD) token, minus any client-specific presentation + * (icon assets stay in each client). + */ +export const MUSD_TOKEN = { + symbol: 'mUSD', + name: 'MetaMask USD', + decimals: 6, + /** + * Remote image URL used when the token is not yet in the user's wallet + * token list and a URI-based image source is needed (e.g. for token avatars + * in confirmation screens). The address casing in the path matches the + * token address on all supported chains. + */ + image: + 'https://static.cx.metamask.io/api/v2/tokenIcons/assets/eip155/1/erc20/0xaca92e438df0b2401ff60da7e4337b687a2435da.png', +} as const; + +/** + * mUSD token decimals (derived from {@link MUSD_TOKEN} for a single source of + * truth). + */ +export const MUSD_DECIMALS = MUSD_TOKEN.decimals; + +/** + * mUSD token address (same on all supported chains). + */ +export const MUSD_TOKEN_ADDRESS: Hex = + '0xaca92e438df0b2401ff60da7e4337b687a2435da'; + +/** + * The mUSD token address on each chain where it is deployed. + */ +export const MUSD_TOKEN_ADDRESS_BY_CHAIN: Record = { + [CHAIN_IDS.MAINNET]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.LINEA_MAINNET]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.BSC]: MUSD_TOKEN_ADDRESS, + [CHAIN_IDS.MONAD]: MUSD_TOKEN_ADDRESS, +}; + +/** + * The CAIP-19 asset id of the mUSD token on each chain where it is deployed. + */ +export const MUSD_TOKEN_ASSET_ID_BY_CHAIN: Record = { + [CHAIN_IDS.MAINNET]: + 'eip155:1/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.LINEA_MAINNET]: + 'eip155:59144/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.BSC]: 'eip155:56/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', + [CHAIN_IDS.MONAD]: + 'eip155:143/erc20:0xacA92E438df0B2401fF60dA7E4337B687a2435DA', +}; + +/** + * The ticker used for mUSD when treated as a currency. + */ +export const MUSD_CURRENCY = 'MUSD'; + +/** + * Chains where the Money Account surfaces mUSD activity. mUSD exists on + * several chains for buy/convert flows, but the Money Account currently only + * tracks Monad — inbound mUSD on Mainnet/Linea/BSC is unrelated to it and + * must not appear in Money activity. + */ +export const MUSD_MONEY_ACCOUNT_CHAIN_IDS: Hex[] = [CHAIN_IDS.MONAD]; + +/** + * Check whether the given token address is mUSD. mUSD has the same address on + * all supported chains. + * + * @param address - The token address to check. + * @returns Whether the address is the mUSD token address. + */ +export function isMusdToken(address?: string): boolean { + if (!address) { + return false; + } + return address.toLowerCase() === MUSD_TOKEN_ADDRESS.toLowerCase(); +} + +/** + * Like {@link isMusdToken} but also requires `chainId` to be a chain where + * mUSD is actually deployed. Prevents a same-address token on an unsupported + * chain from being misclassified as mUSD. + * + * @param address - The token address to check. + * @param chainId - The chain the token lives on. + * @returns Whether the address is mUSD on a chain where mUSD is deployed. + */ +export function isMusdTokenOnChain(address?: string, chainId?: Hex): boolean { + if (!address || !chainId) { + return false; + } + const expected = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!expected) { + return false; + } + return address.toLowerCase() === expected.toLowerCase(); +} + +/** + * Like {@link isMusdTokenOnChain} but restricted to chains where the Money + * Account is active (currently Monad only). + * + * @param address - The token address to check. + * @param chainId - The chain the token lives on. + * @returns Whether the address is mUSD on a Money Account chain. + */ +export function isMusdOnMoneyAccountChain( + address?: string, + chainId?: Hex, +): boolean { + if (!chainId || !MUSD_MONEY_ACCOUNT_CHAIN_IDS.includes(chainId)) { + return false; + } + return isMusdTokenOnChain(address, chainId); +} diff --git a/packages/money-account-utils/tsconfig.build.json b/packages/money-account-utils/tsconfig.build.json new file mode 100644 index 0000000000..4b8f055083 --- /dev/null +++ b/packages/money-account-utils/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../transaction-controller/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-utils/tsconfig.json b/packages/money-account-utils/tsconfig.json new file mode 100644 index 0000000000..e76f473524 --- /dev/null +++ b/packages/money-account-utils/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { + "path": "../transaction-controller" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-utils/typedoc.json b/packages/money-account-utils/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/money-account-utils/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 42326184bf..8991088011 100644 --- a/teams.json +++ b/teams.json @@ -89,6 +89,7 @@ "metamask/config-registry-controller": "team-networks", "metamask/money-account-controller": "team-accounts-framework", "metamask/money-account-upgrade-controller": "team-earn", + "metamask/money-account-utils": "team-earn", "metamask/snap-account-service": "team-accounts-framework", "metamask/platform-api-docs": "team-core-platform", "metamask/smart-transactions-controller": "team-transactions" diff --git a/tsconfig.build.json b/tsconfig.build.json index 9014c6871d..150ccffc75 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -157,9 +157,6 @@ { "path": "./packages/messenger-cli/tsconfig.build.json" }, - { - "path": "./packages/platform-api-docs/tsconfig.build.json" - }, { "path": "./packages/messenger/tsconfig.build.json" }, @@ -172,6 +169,9 @@ { "path": "./packages/money-account-upgrade-controller/tsconfig.build.json" }, + { + "path": "./packages/money-account-utils/tsconfig.build.json" + }, { "path": "./packages/multichain-account-service/tsconfig.build.json" }, @@ -211,6 +211,9 @@ { "path": "./packages/phishing-controller/tsconfig.build.json" }, + { + "path": "./packages/platform-api-docs/tsconfig.build.json" + }, { "path": "./packages/polling-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 488ed40f1c..eca299fede 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -152,9 +152,6 @@ { "path": "./packages/messenger-cli" }, - { - "path": "./packages/platform-api-docs" - }, { "path": "./packages/money-account-balance-service" }, @@ -164,6 +161,9 @@ { "path": "./packages/money-account-upgrade-controller" }, + { + "path": "./packages/money-account-utils" + }, { "path": "./packages/multichain-account-service" }, @@ -203,6 +203,9 @@ { "path": "./packages/phishing-controller" }, + { + "path": "./packages/platform-api-docs" + }, { "path": "./packages/polling-controller" }, diff --git a/yarn.lock b/yarn.lock index 1cdd0464cd..d687a67dba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7534,6 +7534,25 @@ __metadata: languageName: unknown linkType: soft +"@metamask/money-account-utils@workspace:packages/money-account-utils": + version: 0.0.0-use.local + resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/transaction-controller": "npm:^68.2.2" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/multichain-account-service@npm:^11.1.0, @metamask/multichain-account-service@workspace:packages/multichain-account-service": version: 0.0.0-use.local resolution: "@metamask/multichain-account-service@workspace:packages/multichain-account-service"