diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 1c5fc1820d..dd2ac214ee 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -50,6 +50,7 @@ /packages/money-account-api-data-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 @@ -275,5 +276,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 a528c55398..1ff85b5e27 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,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) @@ -199,6 +200,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"]); @@ -436,6 +438,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-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 6a6ff41b47..4d97bf35d0 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `createMoneyAccountUpgradeBootstrap`, the client-agnostic bootstrap orchestration ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Sequences the feature-flag and keyring-unlock gates, guarantees the client's bootstrap action runs at most once, and exposes a `whenReady()` promise gate + - Clients inject signal sources and the bootstrap action via `MoneyAccountUpgradeBootstrapOptions`; chain provisioning stays client-side + ### Changed - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/money-account-upgrade-controller/src/bootstrap.test.ts b/packages/money-account-upgrade-controller/src/bootstrap.test.ts new file mode 100644 index 0000000000..1e83d93bcc --- /dev/null +++ b/packages/money-account-upgrade-controller/src/bootstrap.test.ts @@ -0,0 +1,204 @@ +import type { + MoneyAccountUpgradeBootstrapHandle, + MoneyAccountUpgradeBootstrapOptions, +} from './bootstrap'; +import { createMoneyAccountUpgradeBootstrap } from './bootstrap'; + +type Flags = { enabled: boolean; vaultConfig?: { chainId: string } }; + +const VAULT_CONFIG = { chainId: '0x8f' }; + +type Harness = { + handle: MoneyAccountUpgradeBootstrapHandle; + bootstrap: jest.Mock; + onError: jest.Mock; + unsubscribeFlags: jest.Mock; + unsubscribeUnlock: jest.Mock; + emitFlags: (state: Flags) => void; + hasFlagListener: () => boolean; + emitUnlock: () => void; + hasUnlockListener: () => boolean; +}; + +/** + * Build the orchestrator with controllable fakes for every injected signal. + * + * @param initialFlags - The flag state returned by `getFeatureFlagState`. + * @param unlocked - Whether the keyring starts unlocked. + * @returns The handle plus the fakes for driving signals and asserting calls. + */ +function setup(initialFlags: Flags, unlocked = true): Harness { + let flagListener: ((state: Flags) => void) | undefined; + let unlockListener: (() => void) | undefined; + const unsubscribeFlags = jest.fn(() => { + flagListener = undefined; + }); + const unsubscribeUnlock = jest.fn(() => { + unlockListener = undefined; + }); + const bootstrap = jest.fn().mockResolvedValue(undefined); + const onError = jest.fn(); + + const options: MoneyAccountUpgradeBootstrapOptions< + Flags, + { chainId: string } + > = { + getFeatureFlagState: () => initialFlags, + onFeatureFlagStateChange: (listener) => { + flagListener = listener; + return unsubscribeFlags; + }, + isEnabled: (state) => state.enabled, + getVaultConfig: (state) => state.vaultConfig, + isKeyringUnlocked: () => unlocked, + onKeyringUnlock: (listener) => { + unlockListener = listener; + return unsubscribeUnlock; + }, + bootstrap, + onError, + }; + + const handle = createMoneyAccountUpgradeBootstrap(options); + + return { + handle, + bootstrap, + onError, + unsubscribeFlags, + unsubscribeUnlock, + emitFlags: (state: Flags): void => flagListener?.(state), + hasFlagListener: (): boolean => flagListener !== undefined, + emitUnlock: (): void => unlockListener?.(), + hasUnlockListener: (): boolean => unlockListener !== undefined, + }; +} + +describe('createMoneyAccountUpgradeBootstrap', () => { + it('rejects whenReady before the bootstrap has been scheduled', async () => { + const { handle } = setup({ enabled: false }); + + await expect(handle.whenReady()).rejects.toThrow( + 'MoneyAccountUpgradeController bootstrap has not been scheduled yet', + ); + }); + + it('bootstraps immediately when the flag is on and the keyring is unlocked', async () => { + const { handle, bootstrap, hasFlagListener } = setup({ + enabled: true, + vaultConfig: VAULT_CONFIG, + }); + + handle.start(); + + expect(bootstrap).toHaveBeenCalledWith(VAULT_CONFIG); + // Never subscribed: the initial state was already sufficient. + expect(hasFlagListener()).toBe(false); + expect(await handle.whenReady()).toBeUndefined(); + }); + + it('waits for the flag to turn on, then unsubscribes from flag changes', async () => { + const { handle, bootstrap, emitFlags, unsubscribeFlags } = setup({ + enabled: false, + }); + + handle.start(); + expect(bootstrap).not.toHaveBeenCalled(); + + emitFlags({ enabled: false }); + expect(bootstrap).not.toHaveBeenCalled(); + + emitFlags({ enabled: true, vaultConfig: VAULT_CONFIG }); + expect(bootstrap).toHaveBeenCalledWith(VAULT_CONFIG); + expect(unsubscribeFlags).toHaveBeenCalled(); + expect(await handle.whenReady()).toBeUndefined(); + }); + + it('reports a missing vault config and keeps watching for a usable state', () => { + const { handle, bootstrap, onError, emitFlags } = setup({ enabled: true }); + + handle.start(); + + expect(bootstrap).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Missing Money Account vault config', + }), + 'missing-vault-config', + ); + + emitFlags({ enabled: true, vaultConfig: VAULT_CONFIG }); + expect(bootstrap).toHaveBeenCalledWith(VAULT_CONFIG); + }); + + it('defers the bootstrap until the keyring unlocks, then unsubscribes', async () => { + const { handle, bootstrap, emitUnlock, unsubscribeUnlock } = setup( + { enabled: true, vaultConfig: VAULT_CONFIG }, + false, + ); + + handle.start(); + expect(bootstrap).not.toHaveBeenCalled(); + + emitUnlock(); + expect(bootstrap).toHaveBeenCalledWith(VAULT_CONFIG); + expect(unsubscribeUnlock).toHaveBeenCalled(); + expect(await handle.whenReady()).toBeUndefined(); + }); + + it('schedules the bootstrap at most once across repeated flag states', () => { + const { handle, bootstrap, emitFlags } = setup({ enabled: true }, true); + + handle.start(); + // The missing-config path leaves the flag subscription active; two + // usable states must still bootstrap only once. + emitFlags({ enabled: true, vaultConfig: VAULT_CONFIG }); + emitFlags({ enabled: true, vaultConfig: { chainId: '0x1' } }); + + expect(bootstrap).toHaveBeenCalledTimes(1); + }); + + it('schedules only once even when a flag event fires before unsubscription takes effect', () => { + // Simulates a messenger delivering a queued stateChange after tryStart + // succeeded but before the unsubscribe landed. + let flagListener: ((state: Flags) => void) | undefined; + const bootstrap = jest.fn().mockResolvedValue(undefined); + + const handle = createMoneyAccountUpgradeBootstrap< + Flags, + { chainId: string } + >({ + getFeatureFlagState: () => ({ enabled: false }), + onFeatureFlagStateChange: (listener) => { + flagListener = listener; + return jest.fn(); // unsubscribe that never takes effect + }, + isEnabled: (state) => state.enabled, + getVaultConfig: (state) => state.vaultConfig, + isKeyringUnlocked: () => true, + onKeyringUnlock: () => jest.fn(), + bootstrap, + onError: jest.fn(), + }); + + handle.start(); + flagListener?.({ enabled: true, vaultConfig: VAULT_CONFIG }); + flagListener?.({ enabled: true, vaultConfig: VAULT_CONFIG }); + + expect(bootstrap).toHaveBeenCalledTimes(1); + }); + + it('surfaces a bootstrap failure through onError and whenReady', async () => { + const failure = new Error('init failed'); + const { handle, bootstrap, onError } = setup({ + enabled: true, + vaultConfig: VAULT_CONFIG, + }); + bootstrap.mockRejectedValue(failure); + + handle.start(); + + await expect(handle.whenReady()).rejects.toThrow('init failed'); + expect(onError).toHaveBeenCalledWith(failure, 'bootstrap'); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/bootstrap.ts b/packages/money-account-upgrade-controller/src/bootstrap.ts new file mode 100644 index 0000000000..b932de96ac --- /dev/null +++ b/packages/money-account-upgrade-controller/src/bootstrap.ts @@ -0,0 +1,144 @@ +/** + * Bootstrap orchestration for the MoneyAccountUpgradeController, shared by + * every client. Bootstrapping is controlled by two signals: the money-account + * remote feature flag being on and the keyring being unlocked. Clients inject + * how each signal is read/observed and what the bootstrap itself does (e.g. + * provisioning the chain and calling `controller.init`); the sequencing, + * schedule-once semantics, and ready-promise gate live here. + */ + +type Unsubscribe = () => void; + +/** + * The phase a bootstrap error was raised in: `missing-vault-config` when the + * feature flag is on but carries no vault config, `bootstrap` when the + * client's bootstrap action rejected. + */ +export type MoneyAccountUpgradeBootstrapErrorPhase = + | 'missing-vault-config' + | 'bootstrap'; + +export type MoneyAccountUpgradeBootstrapOptions = { + /** Read the current feature-flag state. */ + getFeatureFlagState: () => FlagsState; + /** + * Observe feature-flag state changes. Returns an unsubscribe function; the + * bootstrap unsubscribes itself once it has been scheduled. + */ + onFeatureFlagStateChange: ( + listener: (state: FlagsState) => void, + ) => Unsubscribe; + /** Whether the money-account feature is enabled in the given state. */ + isEnabled: (state: FlagsState) => boolean; + /** + * Extract the vault config from the given state. Returning undefined while + * `isEnabled` holds is reported as a `missing-vault-config` error. + */ + getVaultConfig: (state: FlagsState) => VaultConfig | undefined; + /** Whether the keyring is currently unlocked. */ + isKeyringUnlocked: () => boolean; + /** + * Observe the keyring-unlock signal. Returns an unsubscribe function; the + * bootstrap unsubscribes itself after the first unlock. + */ + onKeyringUnlock: (listener: () => void) => Unsubscribe; + /** + * The client's bootstrap action, run exactly once — e.g. ensure the chain + * is configured, then `controller.init(...)`. + */ + bootstrap: (vaultConfig: VaultConfig) => Promise; + /** Error sink (e.g. Sentry), keyed by the phase the error was raised in. */ + onError: ( + error: Error, + phase: MoneyAccountUpgradeBootstrapErrorPhase, + ) => void; +}; + +export type MoneyAccountUpgradeBootstrapHandle = { + /** + * Resolves once the bootstrap action has run. Rejects if it failed, or if + * it hasn't been scheduled yet (i.e. the flag is off or the keyring is + * still locked). Callers that depend on the controller being initialized — + * e.g. `upgradeAccount` — should await this first. + */ + whenReady: () => Promise; + /** + * Evaluate the flag state now and either schedule the bootstrap or watch + * for a flag change that enables it. Call once during client init. + */ + start: () => void; +}; + +/** + * Create the money-account upgrade bootstrap orchestrator. + * + * @param options - The injected signal sources and bootstrap action. + * @returns The bootstrap handle: `start` to begin watching, `whenReady` to + * await initialization. + */ +export function createMoneyAccountUpgradeBootstrap( + options: MoneyAccountUpgradeBootstrapOptions, +): MoneyAccountUpgradeBootstrapHandle { + let bootstrapPromise: Promise | null = null; + let bootstrapScheduled = false; + + const whenReady = (): Promise => + bootstrapPromise ?? + Promise.reject( + new Error( + 'MoneyAccountUpgradeController bootstrap has not been scheduled yet', + ), + ); + + const runBootstrap = (vaultConfig: VaultConfig): void => { + bootstrapPromise = options.bootstrap(vaultConfig); + bootstrapPromise.catch((error) => + options.onError(error as Error, 'bootstrap'), + ); + }; + + const scheduleBootstrap = (vaultConfig: VaultConfig): void => { + if (bootstrapScheduled) { + return; + } + bootstrapScheduled = true; + + if (options.isKeyringUnlocked()) { + runBootstrap(vaultConfig); + return; + } + const unsubscribe = options.onKeyringUnlock(() => { + unsubscribe(); + runBootstrap(vaultConfig); + }); + }; + + const tryStart = (state: FlagsState): boolean => { + if (!options.isEnabled(state)) { + return false; + } + const vaultConfig = options.getVaultConfig(state); + if (!vaultConfig) { + options.onError( + new Error('Missing Money Account vault config'), + 'missing-vault-config', + ); + return false; + } + scheduleBootstrap(vaultConfig); + return true; + }; + + const start = (): void => { + if (tryStart(options.getFeatureFlagState())) { + return; + } + const unsubscribe = options.onFeatureFlagStateChange((state) => { + if (tryStart(state)) { + unsubscribe(); + } + }); + }; + + return { whenReady, start }; +} diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts index 784e62cfd5..dcf0f833e0 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,10 @@ export type { UpgradeConfig } from './types'; +export { createMoneyAccountUpgradeBootstrap } from './bootstrap'; +export type { + MoneyAccountUpgradeBootstrapErrorPhase, + MoneyAccountUpgradeBootstrapHandle, + MoneyAccountUpgradeBootstrapOptions, +} from './bootstrap'; export { MoneyAccountUpgradeStepError, isMoneyAccountUpgradeStepError, diff --git a/packages/money-account-utils/CHANGELOG.md b/packages/money-account-utils/CHANGELOG.md new file mode 100644 index 0000000000..fc4e5b384f --- /dev/null +++ b/packages/money-account-utils/CHANGELOG.md @@ -0,0 +1,30 @@ +# 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` +- Add the Money activity list logic, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Domain types: `AccountsApiActivity`, `MoneyActivityItem`, `MoneyActivityFilter`, `MoneyActivityTitleKey`, `MoneyActivityTransactionMeta` with the `onchainItem`/`accountsApiItem` constructors + - Accounts-API parsing: `parseAccountsApiActivity`, `oldestRawActivityTime`, `dedupeAccountsApiActivity`, card payment/cashback type constants + - Merge and pagination gating: `mergeMoneyActivity`, `buildMoneyActivityBuckets` + - Classification: `classifyMoneyActivity`, `getMoneyActivityStatus`, `moneyActivityLabelKey` and the label-key tables (returns neutral kinds and i18n keys; clients map to their own design system and i18n) +- Add the Money Account vault transaction builders, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - `buildMoneyAccountDepositBatch`, `buildMoneyAccountWithdrawBatch`, `applySlippage`, `getSharesForWithdrawal`, `getMoneyAccountDepositAssetAddress`, `TELLER_ABI` + - All configuration (vault addresses, provider) is parameter-injected; no client state access +- Add the Money analytics vocabulary, ported from MetaMask Mobile ([#9397](https://github.com/MetaMask/core/pull/9397)) + - Event name enums (`SCREEN_NAMES`, `BOTTOM_SHEET_NAMES`, `COMPONENT_NAMES`, button/tooltip/onboarding enums) and payload types + - Pure payload builders: `resolveRedirectTargetType`, `withRedirectType` (client URL targets injected), `deriveMoneyActivityTransactionProperties` + +[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..ece2f9af7a --- /dev/null +++ b/packages/money-account-utils/package.json @@ -0,0 +1,80 @@ +{ + "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": { + "@ethersproject/abi": "^5.7.0", + "@ethersproject/contracts": "^5.7.0", + "@ethersproject/providers": "^5.7.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/transaction-controller": "^68.3.0", + "@metamask/utils": "^11.11.0", + "lodash": "^4.17.21" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "@types/lodash": "^4.14.191", + "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/accountsApi.test.ts b/packages/money-account-utils/src/accountsApi.test.ts new file mode 100644 index 0000000000..d7845f7a53 --- /dev/null +++ b/packages/money-account-utils/src/accountsApi.test.ts @@ -0,0 +1,612 @@ +import { + dedupeAccountsApiActivity, + oldestRawActivityTime, + parseAccountsApiActivity, +} from './accountsApi'; +import type { AccountsApiActivity } from './moneyActivity'; + +const MONEY_ADDRESS = '0xbF4bC559f929cE3994Ba12D71d564737357bC8C2'; +const SETTLEMENT_ADDRESS = '0x8dFE562Cbb4E93D5029f39DA26BB6B501a8d1D3e'; +const REWARDER_ADDRESS = '0xfe80eea4249a1f01095d35e0cf4f37367976a9f0'; +const CASHBACK_MULTISEND_TO = '0xC7f1b2228fbf28451c7bf791C4f610111f0f32cb'; + +const cardPaymentRow = { + hash: '0x2b45bda071d8feff265c541e251a5e035e5f55270f8ad288dcd80f6740793847', + timestamp: '2026-06-04T11:53:51.000Z', + chainId: 143, + from: '0x1905d0a43340c81b94468e7dfa5f341ff47ae6a5', + to: '0x40a695a16c213afef1c87fd471fb73157b948f3f', + isError: false, + transactionType: 'METAMASK_CARD_PAYMENT', + valueTransfers: [ + { + from: MONEY_ADDRESS.toLowerCase(), + to: SETTLEMENT_ADDRESS.toLowerCase(), + amount: '5381986', + decimal: 6, + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + }, + ], +}; + +const cashbackRow = { + hash: '0x9c3aa0a1f1f4a8c2d3e4f5061728394a5b6c7d8e9f00112233445566778899aa', + timestamp: '2026-06-04T12:10:00.000Z', + chainId: 143, + from: REWARDER_ADDRESS, + to: MONEY_ADDRESS.toLowerCase(), + isError: false, + transactionType: 'METAMASK_CARD_CASHBACK', + valueTransfers: [ + { + from: REWARDER_ADDRESS, + to: MONEY_ADDRESS.toLowerCase(), + amount: '300000', + decimal: 6, + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + }, + ], +}; + +/** Unclassified Accounts API row — Baanx multisend mUSD payout on Monad. */ +const unclassifiedCashbackRow = { + hash: '0x126be466696f2e3d124c97dedd7a6abd02e31883f544e92f80de732d566b9b16', + timestamp: '2026-06-22T21:41:12.000Z', + chainId: 143, + from: '0xb978703B01a60c7fbD4541D6c29299C65C8e61EA', + to: CASHBACK_MULTISEND_TO, + isError: false, + methodId: '0x0d49b711', + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: '0x21607d4c8cf71844955889890c1711655fd08d72', + to: MONEY_ADDRESS.toLowerCase(), + amount: '999454', + decimal: 6, + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + }, + ], +}; + +const inboundTopUpRow = { + hash: '0x1219eae581c3f3ff44cace3ec51b91c31fa15aecbff612d8bcd058128990e710', + timestamp: '2026-06-04T11:42:02.000Z', + chainId: 143, + from: '0xb42f812a44c22cc6b861478900401ee759ebead6', + to: '0xdb9b1e94b5b69df7e401ddbede43491141047db3', + isError: false, + transactionType: 'GENERIC_CONTRACT_CALL', + valueTransfers: [ + { + from: '0xfe80eea4249a1f01095d35e0cf4f37367976a9f0', + to: MONEY_ADDRESS.toLowerCase(), + amount: '9910542', + decimal: 6, + contractAddress: '0x754704bc059f8c67012fed69bc8a327a5aafb603', + symbol: 'USDC', + }, + ], +}; + +describe('parseAccountsApiActivity', () => { + it('maps a card payment to a card outflow keyed on the leg leaving the money account', () => { + // Arrange — a card payment alongside an unrelated top-up. + const response = { data: [cardPaymentRow, inboundTopUpRow] }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([ + { + kind: 'card', + hash: cardPaymentRow.hash, + time: Date.parse('2026-06-04T11:53:51.000Z'), + chainId: '0x8f', + token: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + decimals: 6, + }, + amount: '5381986', + paidTo: SETTLEMENT_ADDRESS.toLowerCase(), + }, + ]); + }); + + it('maps a cashback row to a cashback inflow keyed on the leg crediting the money account', () => { + // Arrange + const response = { data: [cashbackRow, inboundTopUpRow] }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([ + { + kind: 'cashback', + hash: cashbackRow.hash, + time: Date.parse('2026-06-04T12:10:00.000Z'), + chainId: '0x8f', + token: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + decimals: 6, + }, + amount: '300000', + receivedFrom: REWARDER_ADDRESS, + }, + ]); + }); + + it('parses card and cashback rows from the same response, in input order', () => { + // Arrange + const response = { data: [cardPaymentRow, cashbackRow] }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result.map((a) => a.kind)).toStrictEqual(['card', 'cashback']); + }); + + it('selects the value transfer for the correct direction when legs are mixed', () => { + // Arrange — card payment with the settlement leg listed second. + const response = { + data: [ + { + ...cardPaymentRow, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: MONEY_ADDRESS.toLowerCase(), + amount: '1', + decimal: 6, + contractAddress: '0xaaa', + symbol: 'OTHER', + }, + cardPaymentRow.valueTransfers[0], + ], + }, + ], + }; + + // Act + const [card] = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(card.amount).toBe('5381986'); + expect(card.token.symbol).toBe('mUSD'); + }); + + it('drops malformed rows (missing transfer or bad timestamp) without throwing', () => { + // Arrange + const response = { + data: [ + { ...cardPaymentRow, valueTransfers: [] }, + { ...cashbackRow, timestamp: 'not-a-date' }, + ], + }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([]); + }); + + it('maps an unclassified Baanx multisend payout to cashback when it credits the money account', () => { + // Arrange — Accounts API has not yet tagged the row as METAMASK_CARD_CASHBACK. + const response = { data: [unclassifiedCashbackRow, inboundTopUpRow] }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([ + { + kind: 'cashback', + hash: unclassifiedCashbackRow.hash, + time: Date.parse('2026-06-22T21:41:12.000Z'), + chainId: '0x8f', + token: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + decimals: 6, + }, + amount: '999454', + receivedFrom: '0x21607d4c8cf71844955889890c1711655fd08d72', + }, + ]); + }); + + it('ignores unclassified multisend rows that do not match the cashback heuristics', () => { + expect( + parseAccountsApiActivity( + { + data: [ + { + ...unclassifiedCashbackRow, + to: '0x0000000000000000000000000000000000000001', + }, + { + ...unclassifiedCashbackRow, + methodId: '0xdeadbeef', + }, + ], + }, + MONEY_ADDRESS, + ), + ).toStrictEqual([]); + }); + + it('ignores multisend rows where the money account inbound credit is not mUSD', () => { + expect( + parseAccountsApiActivity( + { + data: [ + { + ...unclassifiedCashbackRow, + valueTransfers: [ + { + from: '0x21607d4c8cf71844955889890c1711655fd08d72', + to: MONEY_ADDRESS.toLowerCase(), + amount: '1000000', + decimal: 6, + contractAddress: '0x754704bc059f8c67012fed69bc8a327a5aafb603', + symbol: 'USDC', + }, + { + from: '0x0000000000000000000000000000000000000000', + to: '0x0000000000000000000000000000000000000001', + amount: '999454', + decimal: 6, + contractAddress: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + }, + ], + }, + ], + }, + MONEY_ADDRESS, + ), + ).toStrictEqual([]); + }); + + it('selects the mUSD inbound leg when multiple inbound transfers credit the money account', () => { + const response = { + data: [ + { + ...unclassifiedCashbackRow, + valueTransfers: [ + { + from: '0x0000000000000000000000000000000000000000', + to: MONEY_ADDRESS.toLowerCase(), + amount: '1', + decimal: 6, + contractAddress: '0x754704bc059f8c67012fed69bc8a327a5aafb603', + symbol: 'USDC', + }, + unclassifiedCashbackRow.valueTransfers[0], + ], + }, + ], + }; + + const [cashback] = parseAccountsApiActivity(response, MONEY_ADDRESS); + + expect(cashback.amount).toBe('999454'); + expect(cashback.token.symbol).toBe('mUSD'); + }); + + it('uses the provided cashback multisend contract list instead of defaults', () => { + const customContract = '0x00000000000000000000000000000000000000aa'; + + expect( + parseAccountsApiActivity( + { data: [unclassifiedCashbackRow] }, + MONEY_ADDRESS, + [customContract], + ), + ).toStrictEqual([]); + + expect( + parseAccountsApiActivity( + { + data: [ + { + ...unclassifiedCashbackRow, + to: customContract, + }, + ], + }, + MONEY_ADDRESS, + [customContract], + ), + ).toStrictEqual([ + expect.objectContaining({ + kind: 'cashback', + hash: unclassifiedCashbackRow.hash, + }), + ]); + }); + + it('ignores rows of other transaction types', () => { + expect( + parseAccountsApiActivity({ data: [inboundTopUpRow] }, MONEY_ADDRESS), + ).toStrictEqual([]); + }); + + it('returns an empty array when there is no data', () => { + expect(parseAccountsApiActivity({}, MONEY_ADDRESS)).toStrictEqual([]); + }); + + it('maps a card payment with an inbound mUSD leg to a refund (a reversed spend)', () => { + // Arrange — mUSD moves back TO the money account; a spend was refunded. + const response = { + data: [ + { + ...cardPaymentRow, + valueTransfers: [ + { + ...cardPaymentRow.valueTransfers[0], + from: SETTLEMENT_ADDRESS.toLowerCase(), + to: MONEY_ADDRESS.toLowerCase(), + }, + ], + }, + ], + }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([ + { + kind: 'refund', + hash: cardPaymentRow.hash, + time: Date.parse('2026-06-04T11:53:51.000Z'), + chainId: '0x8f', + token: { + address: '0xaca92e438df0b2401ff60da7e4337b687a2435da', + symbol: 'mUSD', + decimals: 6, + }, + amount: '5381986', + receivedFrom: SETTLEMENT_ADDRESS.toLowerCase(), + }, + ]); + }); + + it('drops a card payment whose inbound refund leg is not mUSD', () => { + // Arrange — inbound credit, but in a non-mUSD token: not a refund we surface. + const response = { + data: [ + { + ...cardPaymentRow, + valueTransfers: [ + { + ...cardPaymentRow.valueTransfers[0], + from: SETTLEMENT_ADDRESS.toLowerCase(), + to: MONEY_ADDRESS.toLowerCase(), + contractAddress: '0x754704bc059f8c67012fed69bc8a327a5aafb603', + symbol: 'USDC', + }, + ], + }, + ], + }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([]); + }); + + it('drops a cashback row with no leg crediting the money account', () => { + // Arrange — funds leave the money account; not a credit. + const response = { + data: [ + { + ...cashbackRow, + valueTransfers: [ + { + ...cashbackRow.valueTransfers[0], + from: MONEY_ADDRESS.toLowerCase(), + to: REWARDER_ADDRESS, + }, + ], + }, + ], + }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([]); + }); + + it('drops rows that carry no valueTransfers field at all', () => { + const withoutTransfers = ({ + valueTransfers: _ignored, + ...rest + }: Record): Record => rest; + + expect( + parseAccountsApiActivity( + { + data: [ + withoutTransfers(cardPaymentRow), + withoutTransfers(unclassifiedCashbackRow), + ], + } as Parameters[0], + MONEY_ADDRESS, + ), + ).toStrictEqual([]); + }); + + it('ignores a multisend row with no target address at all', () => { + const response = { + data: [{ ...unclassifiedCashbackRow, to: undefined }], + } as unknown as Parameters[0]; + + expect(parseAccountsApiActivity(response, MONEY_ADDRESS)).toStrictEqual([]); + }); + + it('ignores an errored multisend row even when the cashback heuristics match', () => { + expect( + parseAccountsApiActivity( + { data: [{ ...unclassifiedCashbackRow, isError: true }] }, + MONEY_ADDRESS, + ), + ).toStrictEqual([]); + }); + + it('ignores an unclassified multisend row on a non-accepted chain', () => { + expect( + parseAccountsApiActivity( + { data: [{ ...unclassifiedCashbackRow, chainId: 1 }] }, + MONEY_ADDRESS, + ), + ).toStrictEqual([]); + }); + + it('drops rows with a malformed chainId (non-number, non-integer, negative)', () => { + // Cast through `unknown`: these payloads deliberately violate the wire + // types to simulate untrustworthy JSON. + const response = { + data: [ + { ...cardPaymentRow, chainId: '143' }, + { ...cardPaymentRow, chainId: 143.5 }, + { ...cardPaymentRow, chainId: -1 }, + ], + } as unknown as Parameters[0]; + + expect(parseAccountsApiActivity(response, MONEY_ADDRESS)).toStrictEqual([]); + }); + + it('drops rows whose chainId is not an accepted (Monad) chain', () => { + // Arrange — card + cashback indexed on some other chain. + const response = { + data: [ + { ...cardPaymentRow, chainId: 1 }, + { ...cashbackRow, chainId: 1 }, + ], + }; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([]); + }); + + it.each([ + ['decimal missing', { decimal: undefined }], + ['decimal negative', { decimal: -1 }], + ['decimal not an integer', { decimal: 6.5 }], + ['amount non-numeric', { amount: '5.38' }], + ['amount empty', { amount: '' }], + ['amount not a string', { amount: 5381986 }], + ])( + 'drops a row with an untrustworthy settlement leg (%s)', + (_label, override) => { + // Arrange — a wire value that would otherwise render a wrong/NaN amount. + // Cast through `unknown`: these payloads deliberately violate the wire + // types to simulate untrustworthy JSON. + const response = { + data: [ + { + ...cardPaymentRow, + valueTransfers: [ + { ...cardPaymentRow.valueTransfers[0], ...override }, + ], + }, + ], + } as unknown as Parameters[0]; + + // Act + const result = parseAccountsApiActivity(response, MONEY_ADDRESS); + + // Assert + expect(result).toStrictEqual([]); + }, + ); +}); + +describe('oldestRawActivityTime', () => { + const page = ( + ...timestamps: string[] + ): { data: { timestamp: string }[] } => ({ + data: timestamps.map((timestamp) => ({ timestamp })), + }); + + it('returns +Infinity when no pages have been fetched', () => { + expect(oldestRawActivityTime([])).toBe(Number.POSITIVE_INFINITY); + }); + + it('returns +Infinity when fetched pages carry no rows', () => { + expect(oldestRawActivityTime([{ data: [] }, {}])).toBe( + Number.POSITIVE_INFINITY, + ); + }); + + it('returns the oldest raw timestamp across every page', () => { + const oldest = '2026-06-01T00:00:00.000Z'; + const result = oldestRawActivityTime([ + page('2026-06-04T00:00:00.000Z', '2026-06-03T00:00:00.000Z'), + page(oldest, '2026-06-02T00:00:00.000Z'), + ]); + + expect(result).toBe(new Date(oldest).getTime()); + }); + + it('ignores rows with an unparseable timestamp', () => { + const valid = '2026-06-02T00:00:00.000Z'; + const result = oldestRawActivityTime([page('not-a-date', valid)]); + + expect(result).toBe(new Date(valid).getTime()); + }); +}); + +describe('dedupeAccountsApiActivity', () => { + const row = ( + kind: AccountsApiActivity['kind'], + hash: string, + ): AccountsApiActivity => ({ kind, hash, time: 0 }) as AccountsApiActivity; + + it('drops a repeated (kind, hash) pair, keeping the first occurrence', () => { + // An inclusive API cursor can repeat the last row of one page as the + // first of the next. + const deduped = dedupeAccountsApiActivity([ + row('card', '0xAAA'), + row('card', '0xaaa'), + row('card', '0xbbb'), + ]); + + expect(deduped.map((entry) => entry.hash)).toStrictEqual([ + '0xAAA', + '0xbbb', + ]); + }); + + it('keeps rows sharing a hash but differing in kind (spend + its cashback)', () => { + const deduped = dedupeAccountsApiActivity([ + row('card', '0xaaa'), + row('cashback', '0xaaa'), + ]); + + expect(deduped).toHaveLength(2); + }); + + it('returns an empty array for an empty input', () => { + expect(dedupeAccountsApiActivity([])).toStrictEqual([]); + }); +}); diff --git a/packages/money-account-utils/src/accountsApi.ts b/packages/money-account-utils/src/accountsApi.ts new file mode 100644 index 0000000000..9c5ca316ed --- /dev/null +++ b/packages/money-account-utils/src/accountsApi.ts @@ -0,0 +1,340 @@ +import { toHex } from '@metamask/controller-utils'; +import type { Hex } from '@metamask/utils'; + +import type { AccountsApiActivity } from './moneyActivity'; +import { isMusdTokenOnChain, MUSD_MONEY_ACCOUNT_CHAIN_IDS } from './musd'; + +export const METAMASK_CARD_PAYMENT_TYPE = 'METAMASK_CARD_PAYMENT'; +export const METAMASK_CARD_CASHBACK_TYPE = 'METAMASK_CARD_CASHBACK'; + +/** + * Baanx card-cashback multisend contracts observed on Monad and Linea. + * Temporary client-side signal until Accounts API assigns + * `METAMASK_CARD_CASHBACK`. Overridable via the + * `moneyCardActivityCashbackMultisendContracts` remote feature flag. + */ +export const DEFAULT_MONEY_CARD_ACTIVITY_CASHBACK_MULTISEND_CONTRACTS: readonly string[] = + [ + '0x9dd23A4a0845f10d65D293776B792af1131c7B30', + '0xA90b298d05C2667dDC64e2A4e17111357c215dD2', + '0x40A695A16C213afEf1c87Fd471Fb73157b948f3f', + '0x144c1cE815Bd1Eb71678978fE8641cC4e3fd59e6', + '0xC7f1b2228fbf28451c7bf791C4f610111f0f32cb', + ]; + +/** `multiSend` entrypoint on card-cashback payout transactions. */ +const CARD_CASHBACK_MULTISEND_METHOD_ID = '0x0d49b711'; + +const ALLOWED_CHAIN_IDS = new Set( + MUSD_MONEY_ACCOUNT_CHAIN_IDS.map((chainId) => chainId.toLowerCase()), +); + +/** + * Case-insensitive address equality; false when either side is missing. + * + * @param left - The first address. + * @param right - The second address. + * @returns Whether the two addresses are the same account. + */ +function areAddressesEqual(left?: string, right?: string): boolean { + if (!left || !right) { + return false; + } + return left.toLowerCase() === right.toLowerCase(); +} + +type AccountsApiValueTransfer = { + contractAddress: string; + symbol: string; + decimal: number; + from: string; + to: string; + amount: string; +}; + +type AccountsApiTransaction = { + hash: string; + /** ISO-8601, e.g. "2026-06-04T11:53:51.000Z". */ + timestamp: string; + chainId: number; + from: string; + to: string; + isError?: boolean; + /** API-assigned category, e.g. "METAMASK_CARD_PAYMENT". */ + transactionType?: string; + /** Contract call selector, e.g. multisend `0x0d49b711`. */ + methodId?: string; + valueTransfers?: AccountsApiValueTransfer[]; +}; + +type AccountsApiTransactionsResponse = { + data?: AccountsApiTransaction[]; + pageInfo?: { count: number; hasNextPage: boolean; cursor?: string }; +}; + +/** + * Find the leg that leaves the money account — a card spend's settlement. + * + * @param tx - The Accounts-API transaction. + * @param moneyAddress - The money account address. + * @returns The outbound value transfer, or undefined. + */ +function outboundTransfer( + tx: AccountsApiTransaction, + moneyAddress: string, +): AccountsApiValueTransfer | undefined { + return (tx.valueTransfers ?? []).find((vt) => + areAddressesEqual(vt.from, moneyAddress), + ); +} + +/** + * Find the inbound mUSD credit to the money account — the cashback settlement + * leg. + * + * @param tx - The Accounts-API transaction. + * @param moneyAddress - The money account address. + * @returns The inbound mUSD value transfer, or undefined. + */ +function inboundMusdTransfer( + tx: AccountsApiTransaction, + moneyAddress: string, +): AccountsApiValueTransfer | undefined { + const chainId = parseChainId(tx.chainId); + if (!chainId) { + return undefined; + } + return (tx.valueTransfers ?? []).find( + (vt) => + areAddressesEqual(vt.to, moneyAddress) && + isMusdTokenOnChain(vt.contractAddress, chainId), + ); +} + +/** A minimal-unit token amount on the wire: a non-empty string of digits. */ +const AMOUNT_PATTERN = /^\d+$/u; + +/** + * Parse a wire chain id into a Money-account-accepted hex chain id. + * + * @param chainId - The wire chain id. + * @returns The hex chain id, or undefined when invalid or not accepted. + */ +function parseChainId(chainId: unknown): Hex | undefined { + if ( + typeof chainId !== 'number' || + !Number.isInteger(chainId) || + chainId < 0 + ) { + return undefined; + } + const hexChainId = toHex(chainId); + return ALLOWED_CHAIN_IDS.has(hexChainId.toLowerCase()) + ? hexChainId + : undefined; +} + +/** The fields shared by card and cashback rows. */ +type ParsedSettlement = { + hash: Hex; + time: number; + chainId: Hex; + token: { address: Hex; symbol: string; decimals: number }; + amount: string; +}; + +/** + * Parse the settlement fields shared by card and cashback rows, dropping the + * row when any field is untrustworthy. + * + * @param tx - The Accounts-API transaction. + * @param transfer - The settlement value transfer. + * @returns The parsed settlement, or undefined for a malformed row. + */ +function parseSettlement( + tx: AccountsApiTransaction, + transfer: AccountsApiValueTransfer | undefined, +): ParsedSettlement | undefined { + const time = new Date(tx.timestamp).getTime(); + const chainId = parseChainId(tx.chainId); + if ( + !tx.hash || + !transfer || + Number.isNaN(time) || + !chainId || + !Number.isInteger(transfer.decimal) || + transfer.decimal < 0 || + typeof transfer.amount !== 'string' || + !AMOUNT_PATTERN.test(transfer.amount) + ) { + return undefined; + } + return { + hash: tx.hash as Hex, + time, + chainId, + token: { + address: transfer.contractAddress as Hex, + symbol: transfer.symbol, + decimals: transfer.decimal, + }, + amount: transfer.amount, + }; +} + +/** + * Compute the oldest raw (pre-filter) settlement time across the fetched + * Accounts-API pages, in epoch ms. This controls pagination. Because pages + * are fetched newest-first, every API row newer than this has been seen, but + * merged activity older than the watermark must be withheld until more pages + * load. + * + * Computed from the raw rows because a row we don't render still advances how + * far back we've looked. + * + * @param responses - The fetched Accounts-API pages. + * @returns The oldest raw timestamp in epoch ms, or `+Infinity` when no rows + * have been fetched yet, so nothing passes a `time >= watermark` gate until + * the first page arrives. + */ +export function oldestRawActivityTime( + responses: readonly { data?: { timestamp: string }[] }[], +): number { + let oldest = Number.POSITIVE_INFINITY; + for (const response of responses) { + for (const row of response.data ?? []) { + const time = new Date(row.timestamp).getTime(); + if (!Number.isNaN(time) && time < oldest) { + oldest = time; + } + } + } + return oldest; +} + +/** + * Parse an Accounts-API page into Money activity rows. + * + * @param response - The Accounts-API transactions page. + * @param moneyAddress - The money account address. + * @param cashbackMultisendContracts - The Baanx multisend contracts used by + * the cashback heuristics. + * @returns The parsed activity rows; malformed or unrelated rows are dropped. + */ +export function parseAccountsApiActivity( + response: AccountsApiTransactionsResponse, + moneyAddress: string, + cashbackMultisendContracts: readonly string[] = DEFAULT_MONEY_CARD_ACTIVITY_CASHBACK_MULTISEND_CONTRACTS, +): AccountsApiActivity[] { + return (response.data ?? []).flatMap((tx): AccountsApiActivity[] => { + if (tx.transactionType === METAMASK_CARD_PAYMENT_TYPE) { + // A spend debits the money account (outbound leg). A refund of a spend + // reverses that: mUSD is credited back, so there's an inbound leg and + // no outbound one. + const outbound = outboundTransfer(tx, moneyAddress); + if (outbound) { + const parsed = parseSettlement(tx, outbound); + if (!parsed) { + return []; + } + return [{ ...parsed, kind: 'card', paidTo: outbound.to as Hex }]; + } + const inbound = inboundMusdTransfer(tx, moneyAddress); + const parsed = parseSettlement(tx, inbound); + if (!parsed || !inbound) { + return []; + } + return [{ ...parsed, kind: 'refund', receivedFrom: inbound.from as Hex }]; + } + if (isCashbackTransaction(tx, moneyAddress, cashbackMultisendContracts)) { + const transfer = inboundMusdTransfer(tx, moneyAddress); + const parsed = parseSettlement(tx, transfer); + if (!parsed || !transfer) { + return []; + } + return [ + { ...parsed, kind: 'cashback', receivedFrom: transfer.from as Hex }, + ]; + } + return []; + }); +} + +/** + * Check whether an address is one of the known cashback multisend contracts. + * + * @param to - The transaction target address. + * @param cashbackMultisendContracts - The known multisend contracts. + * @returns Whether the target is a cashback multisend contract. + */ +function isCashbackMultisendTarget( + to: string, + cashbackMultisendContracts: readonly string[], +): boolean { + return cashbackMultisendContracts.some((addr) => areAddressesEqual(to, addr)); +} + +/** + * Classify card-cashback rows. Uses the Accounts API type when present; + * otherwise falls back to Baanx multisend heuristics (mUSD payout via a + * known multisend contract). + * + * @param tx - The Accounts-API transaction. + * @param moneyAddress - The money account address. + * @param cashbackMultisendContracts - The known multisend contracts. + * @returns Whether the row is a card cashback payout. + */ +function isCashbackTransaction( + tx: AccountsApiTransaction, + moneyAddress: string, + cashbackMultisendContracts: readonly string[], +): boolean { + if (tx.isError) { + return false; + } + if (tx.transactionType === METAMASK_CARD_CASHBACK_TYPE) { + return true; + } + // Card payments never reach this check: `parseAccountsApiActivity` returns + // for them (spend or refund) before classifying cashback. + if (!parseChainId(tx.chainId)) { + return false; + } + if ( + !isCashbackMultisendTarget(tx.to, cashbackMultisendContracts) || + !inboundMusdTransfer(tx, moneyAddress) + ) { + return false; + } + if ( + tx.methodId && + tx.methodId.toLowerCase() !== + CARD_CASHBACK_MULTISEND_METHOD_ID.toLowerCase() + ) { + return false; + } + return true; +} + +/** + * Drop repeated `(kind, hash)` rows, keeping the first occurrence. If the API + * cursor is ever inclusive (the last row of one page repeated as the first of + * the next), the duplicate would otherwise render twice and collide on the + * list's `id` key. + * + * @param rows - Parsed activity rows, across page boundaries. + * @returns The rows with cross-page duplicates removed. + */ +export function dedupeAccountsApiActivity( + rows: AccountsApiActivity[], +): AccountsApiActivity[] { + const seen = new Set(); + return rows.filter((row) => { + const key = `${row.kind}:${row.hash.toLowerCase()}`; + if (seen.has(key)) { + return false; + } + seen.add(key); + return true; + }); +} diff --git a/packages/money-account-utils/src/classifyMoneyActivity.test.ts b/packages/money-account-utils/src/classifyMoneyActivity.test.ts new file mode 100644 index 0000000000..b4fe4a8863 --- /dev/null +++ b/packages/money-account-utils/src/classifyMoneyActivity.test.ts @@ -0,0 +1,235 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + CHAIN_IDS, + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyActivityKind } from './classifyMoneyActivity'; +import { + classifyMoneyActivity, + getMoneyActivityStatus, + moneyActivityLabelKey, +} from './classifyMoneyActivity'; +import { MUSD_TOKEN_ADDRESS } from './musd'; + +const CHAIN_ID: Hex = '0x1'; +const USDC_ADDRESS: Hex = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48'; + +function makeTx(extra: Record): TransactionMeta { + return { + id: 'tx-1', + chainId: CHAIN_ID, + ...extra, + } as unknown as TransactionMeta; +} + +describe('classifyMoneyActivity', () => { + it('classifies a crypto moneyAccountDeposit as a conversion', () => { + const tx = makeTx({ + type: TransactionType.moneyAccountDeposit, + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + }); + expect(classifyMoneyActivity(tx)).toBe('converted'); + }); + + it('classifies a bare moneyAccountDeposit (no pay metadata) as a conversion', () => { + const tx = makeTx({ type: TransactionType.moneyAccountDeposit }); + expect(classifyMoneyActivity(tx)).toBe('converted'); + }); + + it('classifies a fiat on-ramp moneyAccountDeposit as a deposit', () => { + const tx = makeTx({ + type: TransactionType.moneyAccountDeposit, + metamaskPay: { fiat: { orderId: 'o-1', provider: 'transak-native' } }, + }); + expect(classifyMoneyActivity(tx)).toBe('deposited'); + }); + + it('classifies an mUSD-funded moneyAccountDeposit as a deposit (top-up, not conversion)', () => { + const tx = makeTx({ + type: TransactionType.moneyAccountDeposit, + metamaskPay: { tokenAddress: MUSD_TOKEN_ADDRESS, chainId: CHAIN_ID }, + }); + expect(classifyMoneyActivity(tx)).toBe('deposited'); + }); + + it('classifies musdConversion as a conversion', () => { + expect( + classifyMoneyActivity(makeTx({ type: TransactionType.musdConversion })), + ).toBe('converted'); + }); + + it.each([ + TransactionType.incoming, + TransactionType.tokenMethodTransfer, + TransactionType.tokenMethodTransferFrom, + ])('classifies %s as received', (type) => { + expect(classifyMoneyActivity(makeTx({ type }))).toBe('received'); + }); + + it.each([TransactionType.moneyAccountWithdraw, TransactionType.simpleSend])( + 'classifies %s as sent', + (type) => { + expect(classifyMoneyActivity(makeTx({ type }))).toBe('sent'); + }, + ); + + const MUSD_ON_MONAD = { + tokenAddress: MUSD_TOKEN_ADDRESS, + chainId: CHAIN_IDS.MONAD, + }; + + it.each([ + TransactionType.perpsDeposit, + TransactionType.perpsDepositAndOrder, + TransactionType.predictDeposit, + TransactionType.predictDepositAndOrder, + ])( + 'classifies a money-funded %s as sent (outflow to the service)', + (type) => { + expect( + classifyMoneyActivity(makeTx({ type, metamaskPay: MUSD_ON_MONAD })), + ).toBe('sent'); + }, + ); + + it.each([TransactionType.perpsWithdraw, TransactionType.predictWithdraw])( + 'classifies an mUSD %s as deposited (inflow into the Money account)', + (type) => { + const tx = makeTx({ + type: TransactionType.batch, + nestedTransactions: [{ type }], + metamaskPay: MUSD_ON_MONAD, + }); + expect(classifyMoneyActivity(tx)).toBe('deposited'); + }, + ); + + it('does not treat a non-mUSD perps deposit as a Money outflow', () => { + const tx = makeTx({ + type: TransactionType.perpsDeposit, + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + }); + // Falls through to the default branch rather than the 'sent' early return. + expect(classifyMoneyActivity(tx)).toBe('received'); + }); + + it('resolves the nested type for an EIP-7702 batch crypto deposit', () => { + const tx = makeTx({ + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + }); + expect(classifyMoneyActivity(tx)).toBe('converted'); + }); + + it('resolves the nested type for an EIP-7702 batch withdraw', () => { + const tx = makeTx({ + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.moneyAccountWithdraw }], + }); + expect(classifyMoneyActivity(tx)).toBe('sent'); + }); + + it('falls back to received for a batch with no money-type nested call', () => { + const tx = makeTx({ + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.swap }], + }); + expect(classifyMoneyActivity(tx)).toBe('received'); + }); + + it('defaults an undefined type to deposited', () => { + expect(classifyMoneyActivity(makeTx({ type: undefined }))).toBe( + 'deposited', + ); + }); + + it('lets an explicit moneyActivityTitleKey override the derived kind', () => { + const tx = makeTx({ + // A crypto deposit would derive "converted"; the title key wins. + type: TransactionType.moneyAccountDeposit, + metamaskPay: { tokenAddress: USDC_ADDRESS, chainId: CHAIN_ID }, + moneyActivityTitleKey: 'deposited', + }); + expect(classifyMoneyActivity(tx)).toBe('deposited'); + }); + + it('maps the card_transaction title key to the card kind', () => { + const tx = makeTx({ + type: TransactionType.moneyAccountWithdraw, + moneyActivityTitleKey: 'card_transaction', + }); + expect(classifyMoneyActivity(tx)).toBe('card'); + }); + + it('falls back to received for an unknown title key', () => { + const tx = makeTx({ + type: TransactionType.moneyAccountWithdraw, + moneyActivityTitleKey: 'not-a-real-key', + }); + expect(classifyMoneyActivity(tx)).toBe('received'); + }); +}); + +describe('getMoneyActivityStatus', () => { + it.each([ + // approved/signed = held by the MetaMask Pay publish hook while a + // cross-chain payment completes — in-flight, not mid-compose. + [TransactionStatus.approved, 'pending'], + [TransactionStatus.signed, 'pending'], + [TransactionStatus.submitted, 'pending'], + [TransactionStatus.failed, 'failed'], + [TransactionStatus.confirmed, 'confirmed'], + [undefined, 'confirmed'], + ])('maps tx.status %s to %s', (status, expected) => { + expect(getMoneyActivityStatus(makeTx({ status }))).toBe(expected); + }); +}); + +describe('moneyActivityLabelKey — confirmed labels', () => { + it.each([ + ['deposited', 'money.transaction.deposited'], + ['received', 'money.transaction.received'], + ['converted', 'money.transaction.converted'], + ['sent', 'money.transaction.sent'], + ['card', 'money.transaction.card_transaction'], + ['cashback', 'money.transaction.cashback'], + ] as [MoneyActivityKind, string][])('kind "%s" → key %s', (kind, key) => { + expect(moneyActivityLabelKey(kind, 'confirmed')).toBe(key); + }); +}); + +describe('moneyActivityLabelKey — pending (present-tense) labels', () => { + it.each([ + ['deposited', 'money.transaction.depositing'], + ['converted', 'money.transaction.converting'], + ['sent', 'money.transaction.sending'], + ['received', 'money.transaction.receiving'], + ] as [MoneyActivityKind, string][])('kind "%s" pending → %s', (kind, key) => { + expect(moneyActivityLabelKey(kind, 'pending')).toBe(key); + }); + + it('falls back to the confirmed key for kinds with no pending form', () => { + expect(moneyActivityLabelKey('card', 'pending')).toBe( + 'money.transaction.card_transaction', + ); + }); +}); + +describe('moneyActivityLabelKey — failed labels', () => { + it.each([ + ['deposited', 'money.transaction.deposit_failed'], + ['converted', 'money.transaction.conversion_failed'], + ['sent', 'money.transaction.send_failed'], + ] as [MoneyActivityKind, string][])('kind "%s" failed → %s', (kind, key) => { + expect(moneyActivityLabelKey(kind, 'failed')).toBe(key); + }); + + it('falls back to the confirmed key for kinds with no failed form', () => { + expect(moneyActivityLabelKey('received', 'failed')).toBe( + 'money.transaction.received', + ); + }); +}); diff --git a/packages/money-account-utils/src/classifyMoneyActivity.ts b/packages/money-account-utils/src/classifyMoneyActivity.ts new file mode 100644 index 0000000000..9adc3da6f3 --- /dev/null +++ b/packages/money-account-utils/src/classifyMoneyActivity.ts @@ -0,0 +1,206 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; + +import type { + MoneyActivityTitleKey, + MoneyActivityTransactionMeta, +} from './moneyActivity'; +import { + isPerpsPredictMoneyDeposit, + isPerpsPredictMoneyWithdraw, +} from './moneyTransactionGuards'; +import { isMusdToken } from './musd'; + +export type MoneyActivityStatus = 'pending' | 'confirmed' | 'failed'; + +/** + * Map a transaction's status onto the Money activity display status. + * + * @param tx - The transaction to inspect. + * @returns The activity display status. + */ +export function getMoneyActivityStatus( + tx: TransactionMeta, +): MoneyActivityStatus { + switch (tx.status) { + // `approved`/`signed` = user has confirmed but the tx is held by the + // MetaMask Pay publish hook while a cross-chain payment (e.g. bridge) + // completes — in-flight from the user's perspective. + case TransactionStatus.approved: + case TransactionStatus.signed: + case TransactionStatus.submitted: + return 'pending'; + case TransactionStatus.failed: + return 'failed'; + default: + return 'confirmed'; + } +} + +export type MoneyActivityKind = + | 'deposited' + | 'received' + | 'converted' + | 'sent' + | 'card' + | 'cashback'; + +const TITLE_KEY_TO_KIND: Record = { + deposited: 'deposited', + received: 'received', + card_transaction: 'card', + converted: 'converted', + sent: 'sent', +}; + +/** + * Resolve the money-moving transaction type, unwrapping an EIP-7702 `batch` + * whose money call sits in `nestedTransactions`. + * + * @param tx - The transaction to inspect. + * @returns The effective transaction type. + */ +function resolveMoneyTransactionType( + tx: TransactionMeta, +): TransactionType | undefined { + if (tx.type === TransactionType.batch) { + const nestedMoneyType = tx.nestedTransactions?.find( + (nested) => + nested.type === TransactionType.moneyAccountDeposit || + nested.type === TransactionType.moneyAccountWithdraw, + )?.type; + if (nestedMoneyType) { + return nestedMoneyType; + } + } + return tx.type; +} + +/** + * Check for a `moneyAccountDeposit` funded by a fiat on-ramp (e.g. Transak), + * not crypto. + * + * @param tx - The transaction to inspect. + * @returns Whether the deposit was funded with fiat. + */ +function isFiatDeposit(tx: TransactionMeta): boolean { + return Boolean(tx.metamaskPay?.fiat); +} + +/** + * Check for a `moneyAccountDeposit` paid with mUSD itself. + * + * @param tx - The transaction to inspect. + * @returns Whether the deposit was paid with mUSD. + */ +function isMusdPayToken(tx: TransactionMeta): boolean { + return isMusdToken(tx.metamaskPay?.tokenAddress); +} + +/** + * Classify a transaction into its Money activity kind. + * + * @param tx - The transaction to classify. + * @returns The neutral activity kind for the row. + */ +export function classifyMoneyActivity(tx: TransactionMeta): MoneyActivityKind { + const { moneyActivityTitleKey } = tx as MoneyActivityTransactionMeta; + if (moneyActivityTitleKey) { + return TITLE_KEY_TO_KIND[moneyActivityTitleKey] ?? 'received'; + } + + // Perps/Predict ↔ Money transfers (matched via the mUSD pay token). Withdraw + // into the Money account reads as a deposit; deposit out of it reads as sent. + if (isPerpsPredictMoneyWithdraw(tx)) { + return 'deposited'; + } + if (isPerpsPredictMoneyDeposit(tx)) { + return 'sent'; + } + + const type = resolveMoneyTransactionType(tx); + if (!type) { + return 'deposited'; + } + + switch (type) { + case TransactionType.moneyAccountDeposit: + if (isFiatDeposit(tx) || isMusdPayToken(tx)) { + return 'deposited'; + } + return 'converted'; + case TransactionType.musdConversion: + return 'converted'; + case TransactionType.incoming: + case TransactionType.tokenMethodTransfer: + case TransactionType.tokenMethodTransferFrom: + return 'received'; + case TransactionType.moneyAccountWithdraw: + case TransactionType.simpleSend: + return 'sent'; + default: + return 'received'; + } +} + +/** + * The i18n label key for each activity kind's confirmed (past-tense) form. + */ +export const MONEY_ACTIVITY_KIND_LABEL_KEY: Record = + { + deposited: 'money.transaction.deposited', + received: 'money.transaction.received', + converted: 'money.transaction.converted', + sent: 'money.transaction.sent', + card: 'money.transaction.card_transaction', + cashback: 'money.transaction.cashback', + }; + +/** + * Present-tense label keys for in-flight rows (e.g. "Depositing"). Kinds + * without an entry fall back to the confirmed key. + */ +export const MONEY_ACTIVITY_KIND_PENDING_LABEL_KEY: Partial< + Record +> = { + deposited: 'money.transaction.depositing', + converted: 'money.transaction.converting', + sent: 'money.transaction.sending', + received: 'money.transaction.receiving', +}; + +/** + * Failed-state label keys. Kinds without an entry fall back to the confirmed + * key. + */ +export const MONEY_ACTIVITY_KIND_FAILED_LABEL_KEY: Partial< + Record +> = { + deposited: 'money.transaction.deposit_failed', + converted: 'money.transaction.conversion_failed', + sent: 'money.transaction.send_failed', +}; + +/** + * Resolve the i18n label key for an activity row. Returns a key, not a + * localized string — each client passes it through its own i18n layer. + * + * @param kind - The activity kind. + * @param status - The activity display status. + * @returns The i18n key for the row's label. + */ +export function moneyActivityLabelKey( + kind: MoneyActivityKind, + status: MoneyActivityStatus, +): string { + let statusKey: string | undefined; + if (status === 'pending') { + statusKey = MONEY_ACTIVITY_KIND_PENDING_LABEL_KEY[kind]; + } else if (status === 'failed') { + statusKey = MONEY_ACTIVITY_KIND_FAILED_LABEL_KEY[kind]; + } + return statusKey ?? MONEY_ACTIVITY_KIND_LABEL_KEY[kind]; +} diff --git a/packages/money-account-utils/src/index.ts b/packages/money-account-utils/src/index.ts new file mode 100644 index 0000000000..5257d8e8c8 --- /dev/null +++ b/packages/money-account-utils/src/index.ts @@ -0,0 +1,106 @@ +export { + BOTTOM_SHEET_NAMES, + COMPONENT_NAMES, + MONEY_BUTTON_INTENTS, + MONEY_BUTTON_TYPES, + MONEY_ONBOARDING_STEP_ACTIONS, + MONEY_SURFACE_TYPES, + MONEY_TOOLTIP_NAMES, + MONEY_TOOLTIP_TYPES, + REDIRECT_TARGETS_TYPES, + SCREEN_NAMES, + deriveMoneyActivityTransactionProperties, + resolveRedirectTargetType, + withRedirectType, +} from './moneyEvents'; +export type { MoneyActivityTransactionProperties } from './moneyEvents'; +export type { + MoneyActivitySurfaceClickedEventProperties, + MoneyBaseEventProperties, + MoneyButtonClickedEventProperties, + MoneyButtonClickedInputProperties, + MoneyCardEventProperties, + MoneyLocationEventProperties, + MoneyOnboardingEventProperties, + MoneyRedirectEventProperties, + MoneyRedirectTarget, + MoneySurfaceClickedEventProperties, + MoneyTextButtonClickedEventProperties, + MoneyTokenRowButtonClickedEventProperties, + MoneyTokenRowButtonClickedInputProperties, + MoneyTokenSurfaceClickedEventProperties, + MoneyTooltipClickedEventProperties, +} from './moneyEvents.types'; +export type { + MoneyAccountDepositBatchResult, + MoneyAccountTxParams, + MoneyAccountWithdrawBatchResult, +} from './moneyAccountTransactions'; +export { + TELLER_ABI, + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountWithdrawBatch, + getMoneyAccountDepositAssetAddress, + getSharesForWithdrawal, +} from './moneyAccountTransactions'; +export type { + AccountsApiActivity, + MoneyActivityBuckets, + MoneyActivityItem, + MoneyActivityTitleKey, + MoneyActivityTransactionMeta, +} from './moneyActivity'; +export { + MoneyActivityFilter, + accountsApiItem, + buildMoneyActivityBuckets, + mergeMoneyActivity, + onchainItem, +} from './moneyActivity'; +export { + DEFAULT_MONEY_CARD_ACTIVITY_CASHBACK_MULTISEND_CONTRACTS, + METAMASK_CARD_CASHBACK_TYPE, + METAMASK_CARD_PAYMENT_TYPE, + dedupeAccountsApiActivity, + oldestRawActivityTime, + parseAccountsApiActivity, +} from './accountsApi'; +export type { + MoneyActivityKind, + MoneyActivityStatus, +} from './classifyMoneyActivity'; +export { + MONEY_ACTIVITY_KIND_FAILED_LABEL_KEY, + MONEY_ACTIVITY_KIND_LABEL_KEY, + MONEY_ACTIVITY_KIND_PENDING_LABEL_KEY, + classifyMoneyActivity, + getMoneyActivityStatus, + moneyActivityLabelKey, +} from './classifyMoneyActivity'; +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/moneyAccountTransactions.test.ts b/packages/money-account-utils/src/moneyAccountTransactions.test.ts new file mode 100644 index 0000000000..d54b9a3ce0 --- /dev/null +++ b/packages/money-account-utils/src/moneyAccountTransactions.test.ts @@ -0,0 +1,362 @@ +import { Interface } from '@ethersproject/abi'; +import { Contract } from '@ethersproject/contracts'; +import type { Provider } from '@ethersproject/providers'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { + applySlippage, + buildMoneyAccountDepositBatch, + buildMoneyAccountWithdrawBatch, + getMoneyAccountDepositAssetAddress, + getSharesForWithdrawal, + TELLER_ABI, +} from './moneyAccountTransactions'; +import { MUSD_TOKEN_ADDRESS } from './musd'; + +const mockPreviewDeposit = jest.fn(); +const mockGetRate = jest.fn(); + +jest.mock('@ethersproject/contracts'); + +// Monad — a chain where mUSD is deployed, so the real address map resolves. +const MOCK_CHAIN_ID = '0x8f' as Hex; +const MOCK_BORING_VAULT = '0xB5F07d769dD60fE54c97dd53101181073DDf21b2' as Hex; +const MOCK_TELLER = '0x86821F179eaD9F0b3C79b2f8deF0227eEBFDc9f9' as Hex; +const MOCK_ACCOUNTANT = '0x800ebc3B74F67EaC27C9CCE4E4FF28b17CdCA173' as Hex; +const MOCK_LENS = '0x846a7832022350434B5cC006d07cc9c782469660' as Hex; +const MOCK_MONEY_ACCOUNT_ADDRESS = + '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; +const MOCK_RECIPIENT_ADDRESS = + '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd' as Hex; +const MOCK_PROVIDER = {} as Provider; + +const decodeWithdraw = ( + data: Hex, +): ReturnType => + new Interface(TELLER_ABI).decodeFunctionData('withdraw', data); + +/** + * (Re)install the Contract fake. `resetMocks` wipes module-mock + * implementations before each test, so this runs in a `beforeEach` inside the + * describes that build calldata, rather than in the mock factory. + */ +function installContractFake(): void { + jest.mocked(Contract).mockImplementation( + (_address, abi): Contract => + ({ + previewDeposit: mockPreviewDeposit, + getRate: mockGetRate, + // Sanity marker so a wrong-ABI lookup fails loudly in tests. + abi, + }) as unknown as Contract, + ); +} + +describe('applySlippage', () => { + it('applies 0.2% slippage to a round value', () => { + expect(applySlippage(BigInt(1000))).toBe(BigInt(998)); + }); + + it('applies 0.2% slippage with integer truncation', () => { + expect(applySlippage(BigInt(1))).toBe(BigInt(0)); + }); + + it('applies 0.2% slippage to a large value', () => { + const amount = BigInt('1000000000000000000'); + const expected = (amount * BigInt(998)) / BigInt(1000); + expect(applySlippage(amount)).toBe(expected); + }); + + it('returns 0 for 0 input', () => { + expect(applySlippage(BigInt(0))).toBe(BigInt(0)); + }); +}); + +describe('getSharesForWithdrawal', () => { + const SHARE_SCALAR = BigInt(1_000_000); + + it('converts amount to shares at 1:1 rate (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(1_000_000))).toBe( + BigInt(1_000_000), + ); + }); + + it('scales down when rate is higher than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(1_000_000), BigInt(2_000_000))).toBe( + BigInt(500_000), + ); + }); + + it('scales up when rate is lower than 1:1 (exact division)', () => { + expect(getSharesForWithdrawal(BigInt(2_000_000), BigInt(1_000_000))).toBe( + BigInt(2_000_000), + ); + }); + + it('uses ceiling division — rounds up when remainder exists', () => { + // 1_000_000 * 1_000_000 = 1_000_000_000_000 + // floor(1_000_000_000_000 / 3_000_000) = 333_333; ceiling = 333_334 + const amount = BigInt(1_000_000); + const rate = BigInt(3_000_000); + expect((amount * SHARE_SCALAR) / rate).toBe(BigInt(333_333)); + expect(getSharesForWithdrawal(amount, rate)).toBe(BigInt(333_334)); + }); + + it('reproduces the exact reported scenario — $1.96 at rate ~1,000,094', () => { + // This was the failing case: floor division gave 1,959,815 shares, + // contract mulDivDown produced 1,959,999 assetsOut < 1,960,000 minimumAssets + const amount = BigInt(1_960_000); // $1.96 in 6 decimals + const rate = BigInt(1_000_094); + + const floorShares = (amount * SHARE_SCALAR) / rate; + expect(floorShares).toBe(BigInt(1_959_815)); // old buggy value + + const ceilShares = getSharesForWithdrawal(amount, rate); + expect(ceilShares).toBe(BigInt(1_959_816)); // fixed: one more share + + // Verify: contract mulDivDown(ceilShares * rate / SCALAR) >= amount + const assetsOut = (ceilShares * rate) / SHARE_SCALAR; + expect(assetsOut).toBeGreaterThanOrEqual(amount); + }); + + it('reproduces the reported $1.00 scenario — was passing by luck', () => { + const amount = BigInt(1_000_000); + const rate = BigInt(1_000_094); + + const floorShares = (amount * SHARE_SCALAR) / rate; + const ceilShares = getSharesForWithdrawal(amount, rate); + + // With ceiling, we get at least as many shares as floor + expect(ceilShares).toBeGreaterThanOrEqual(floorShares); + + // Contract-side check still passes + const assetsOut = (ceilShares * rate) / SHARE_SCALAR; + expect(assetsOut).toBeGreaterThanOrEqual(amount); + }); + + it('handles large amounts with ceiling division', () => { + const amount = BigInt('1000000000000'); // $1M in 6 decimals + const rate = BigInt('1500000'); + const result = getSharesForWithdrawal(amount, rate); + const floorResult = (amount * SHARE_SCALAR) / rate; + // Ceiling >= floor always + expect(result).toBeGreaterThanOrEqual(floorResult); + // And at most 1 more than floor + expect(result - floorResult).toBeLessThanOrEqual(1n); + }); + + it('ceiling division equals floor when division is exact', () => { + // 2_000_000 * 1_000_000 / 500_000 = 4_000_000_000 (exact) + const amount = BigInt(2_000_000); + const rate = BigInt(500_000); + const floorResult = (amount * SHARE_SCALAR) / rate; + expect(getSharesForWithdrawal(amount, rate)).toBe(floorResult); + }); + + it('returns 0 for zero amount', () => { + expect(getSharesForWithdrawal(0n, BigInt(1_000_000))).toBe(0n); + }); + + it('guarantees assetsOut >= amount for many rate values', () => { + // Fuzz-like: sweep a range of rates near 1:1 + const amount = BigInt(1_960_000); + for (let rawRate = 999_900; rawRate <= 1_000_200; rawRate++) { + const rate = BigInt(rawRate); + const shares = getSharesForWithdrawal(amount, rate); + // Simulate contract mulDivDown + const assetsOut = (shares * rate) / SHARE_SCALAR; + expect(assetsOut).toBeGreaterThanOrEqual(amount); + } + }); +}); + +describe('getMoneyAccountDepositAssetAddress', () => { + it('returns the mUSD address for a chain where mUSD is deployed', () => { + expect(getMoneyAccountDepositAssetAddress(MOCK_CHAIN_ID)).toBe( + MUSD_TOKEN_ADDRESS, + ); + }); + + it('throws for a chain where mUSD is not deployed', () => { + expect(() => getMoneyAccountDepositAssetAddress('0x89' as Hex)).toThrow( + 'mUSD not deployed on chain 0x89', + ); + }); +}); + +describe('buildMoneyAccountDepositBatch', () => { + beforeEach(installContractFake); + + const buildDeposit = ( + amount: bigint, + ): ReturnType => + buildMoneyAccountDepositBatch({ + amount, + chainId: MOCK_CHAIN_ID, + boringVault: MOCK_BORING_VAULT, + tellerAddress: MOCK_TELLER, + accountantAddress: MOCK_ACCOUNTANT, + lensAddress: MOCK_LENS, + provider: MOCK_PROVIDER, + }); + + it('returns approve and deposit transactions with correct types', async () => { + mockPreviewDeposit.mockResolvedValue('1000000'); + + const result = await buildDeposit(BigInt(1_000_000)); + + expect(result.approveTx.type).toBe(TransactionType.tokenMethodApprove); + expect(result.approveTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.approveTx.params.value).toBe('0x0'); + + expect(result.depositTx.type).toBe(TransactionType.moneyAccountDeposit); + expect(result.depositTx.params.to).toBe(MOCK_TELLER); + expect(result.depositTx.params.value).toBe('0x0'); + }); + + it('encodes approve data targeting the boring vault', async () => { + mockPreviewDeposit.mockResolvedValue('1000000'); + + const result = await buildDeposit(BigInt(500_000)); + + const decoded = new Interface([ + 'function approve(address spender, uint256 amount)', + ]).decodeFunctionData('approve', result.approveTx.params.data); + expect(decoded.spender.toLowerCase()).toBe(MOCK_BORING_VAULT.toLowerCase()); + expect(BigInt(decoded.amount.toString())).toBe(BigInt(500_000)); + }); + + it('calls previewDeposit with correct arguments and applies slippage to minimumMint', async () => { + mockPreviewDeposit.mockResolvedValue('1000000'); + + const result = await buildDeposit(BigInt(1_000_000)); + + expect(mockPreviewDeposit).toHaveBeenCalledWith( + MUSD_TOKEN_ADDRESS, + '1000000', + MOCK_BORING_VAULT, + MOCK_ACCOUNTANT, + ); + + const decoded = new Interface(TELLER_ABI).decodeFunctionData( + 'deposit', + result.depositTx.params.data, + ); + // 0.2% slippage on the previewed 1_000_000 shares. + expect(BigInt(decoded.minimumMint.toString())).toBe(BigInt(998_000)); + }); + + it('skips the preview call and encodes minimumMint 0 for a zero-amount placeholder batch', async () => { + const result = await buildDeposit(0n); + + expect(mockPreviewDeposit).not.toHaveBeenCalled(); + + const decoded = new Interface(TELLER_ABI).decodeFunctionData( + 'deposit', + result.depositTx.params.data, + ); + expect(BigInt(decoded.minimumMint.toString())).toBe(0n); + }); +}); + +describe('buildMoneyAccountWithdrawBatch', () => { + beforeEach(installContractFake); + + const buildWithdraw = ( + amount: bigint, + ): ReturnType => + buildMoneyAccountWithdrawBatch({ + amount, + chainId: MOCK_CHAIN_ID, + tellerAddress: MOCK_TELLER, + accountantAddress: MOCK_ACCOUNTANT, + moneyAccountAddress: MOCK_MONEY_ACCOUNT_ADDRESS, + recipient: MOCK_RECIPIENT_ADDRESS, + provider: MOCK_PROVIDER, + }); + + it('returns withdrawTx and transferTx with correct transaction types', async () => { + mockGetRate.mockResolvedValue('1000000'); + + const result = await buildWithdraw(BigInt(1_000_000)); + + expect(result.withdrawTx.type).toBe(TransactionType.moneyAccountWithdraw); + expect(result.withdrawTx.params.to).toBe(MOCK_TELLER); + expect(result.withdrawTx.params.value).toBe('0x0'); + + expect(result.transferTx.type).toBe(TransactionType.tokenMethodTransfer); + expect(result.transferTx.params.value).toBe('0x0'); + }); + + it('targets the token contract (not the recipient) with the transfer', async () => { + mockGetRate.mockResolvedValue('1000000'); + + const result = await buildWithdraw(BigInt(1_000_000)); + + expect(result.transferTx.params.to).toBe(MUSD_TOKEN_ADDRESS); + expect(result.transferTx.params.to).not.toBe(MOCK_RECIPIENT_ADDRESS); + }); + + it('encodes the recipient and amount in the transfer calldata', async () => { + mockGetRate.mockResolvedValue('1000000'); + + const result = await buildWithdraw(BigInt(1_000_000)); + + const decoded = new Interface([ + 'function transfer(address to, uint256 amount)', + ]).decodeFunctionData('transfer', result.transferTx.params.data); + expect(decoded.to.toLowerCase()).toBe(MOCK_RECIPIENT_ADDRESS.toLowerCase()); + expect(BigInt(decoded.amount.toString())).toBe(BigInt(1_000_000)); + }); + + it('calls getRate on the accountant contract', async () => { + mockGetRate.mockResolvedValue('2000000'); + + await buildWithdraw(BigInt(1_000_000)); + + expect(mockGetRate).toHaveBeenCalledTimes(1); + }); + + it('skips getRate when amount is 0 (placeholder batch)', async () => { + const result = await buildWithdraw(BigInt(0)); + + expect(mockGetRate).not.toHaveBeenCalled(); + expect(result.withdrawTx.params.data.startsWith('0x')).toBe(true); + expect(result.transferTx.params.data.startsWith('0x')).toBe(true); + }); + + it('encodes minimumAssets as amount - 1 for defense-in-depth', async () => { + mockGetRate.mockResolvedValue('1000000'); + + const amount = BigInt(1_960_000); + const result = await buildWithdraw(amount); + + const decoded = decodeWithdraw(result.withdrawTx.params.data); + expect(BigInt(decoded.minimumAssets.toString())).toBe(amount - 1n); + expect(decoded.to.toLowerCase()).toBe( + MOCK_MONEY_ACCOUNT_ADDRESS.toLowerCase(), + ); + }); + + it('encodes minimumAssets and shareAmount as 0 when amount is 0 (placeholder batch)', async () => { + const result = await buildWithdraw(BigInt(0)); + + const decoded = decodeWithdraw(result.withdrawTx.params.data); + expect(BigInt(decoded.minimumAssets.toString())).toBe(0n); + expect(BigInt(decoded.shareAmount.toString())).toBe(0n); + }); + + it('uses ceiling division for shareAmount in withdraw calldata', async () => { + // A rate that produces a remainder, to verify ceiling division. + mockGetRate.mockResolvedValue('1000094'); + + const amount = BigInt(1_960_000); + const result = await buildWithdraw(amount); + + const decoded = decodeWithdraw(result.withdrawTx.params.data); + expect(BigInt(decoded.shareAmount.toString())).toBe( + getSharesForWithdrawal(amount, BigInt(1_000_094)), + ); + }); +}); diff --git a/packages/money-account-utils/src/moneyAccountTransactions.ts b/packages/money-account-utils/src/moneyAccountTransactions.ts new file mode 100644 index 0000000000..fe7ca81cb4 --- /dev/null +++ b/packages/money-account-utils/src/moneyAccountTransactions.ts @@ -0,0 +1,404 @@ +import { Interface } from '@ethersproject/abi'; +import { Contract } from '@ethersproject/contracts'; +import type { Provider } from '@ethersproject/providers'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { MUSD_TOKEN_ADDRESS_BY_CHAIN } from './musd'; + +const LENS_ABI = [ + 'function previewDeposit(address depositAsset, uint256 depositAmount, address boringVault, address accountant) view returns (uint256 shares)', +]; + +export const TELLER_ABI = [ + 'function deposit(address depositAsset, uint256 depositAmount, uint256 minimumMint, address referralAddress) payable returns (uint256 shares)', + 'function withdraw(address withdrawAsset, uint256 shareAmount, uint256 minimumAssets, address to) returns (uint256 assetsOut)', +]; + +const ACCOUNTANT_ABI = ['function getRate() view returns (uint256 rate)']; + +const ERC20_ABI = [ + 'function approve(address spender, uint256 amount)', + 'function transfer(address to, uint256 amount)', +]; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; + +// -- Shared constants ------------------------------------------------------ + +const SLIPPAGE_NUMERATOR = BigInt(998); +const SLIPPAGE_DENOMINATOR = BigInt(1000); + +/** + * Apply a 0.2% slippage tolerance to a bigint value. If this sanity-check + * causes a revert, no funds are lost — retry with a fresh quote. + * + * @param value - The value to apply slippage to. + * @returns The value reduced by the slippage tolerance. + */ +export function applySlippage(value: bigint): bigint { + return (value * SLIPPAGE_NUMERATOR) / SLIPPAGE_DENOMINATOR; +} + +// -- Shared types ---------------------------------------------------------- + +export type MoneyAccountTxParams = { + params: { + to: Hex; + data: Hex; + value: Hex; + }; + type: TransactionType; +}; + +/** + * Result shape for Money Account transaction batch builders. The string keys + * (e.g. `approveTx`, `withdrawTx`) name each call so callers don't depend on + * positional ordering in `addTransactionBatch.transactions[]`. + */ +type MoneyAccountBatchResult = Record< + TxKey, + MoneyAccountTxParams +>; + +// -- Deposit helpers ------------------------------------------------------- + +/** + * Preview the vault shares minted for a deposit via the lens contract. + * + * @param args - The preview arguments. + * @param args.lensAddress - The lens contract address. + * @param args.boringVault - The vault address. + * @param args.accountantAddress - The accountant contract address. + * @param args.musdAddress - The deposit asset (mUSD) address. + * @param args.amount - The deposit amount in minimal units. + * @param args.provider - The chain's read provider. + * @returns The expected share amount. + */ +async function getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, +}: { + lensAddress: string; + boringVault: string; + accountantAddress: string; + musdAddress: string; + amount: bigint; + provider: Provider; +}): Promise { + const lensContract = new Contract(lensAddress, LENS_ABI, provider); + const shares = await lensContract.previewDeposit( + musdAddress, + amount.toString(), + boringVault, + accountantAddress, + ); + return BigInt(shares.toString()); +} + +/** + * Encode an ERC-20 `approve` for the vault. + * + * @param boringVault - The spender (vault) address. + * @param amount - The approval amount in minimal units. + * @returns The encoded calldata. + */ +function buildApproveData(boringVault: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('approve', [ + boringVault, + amount.toString(), + ]) as Hex; +} + +/** + * Encode an ERC-20 `transfer`. + * + * @param to - The recipient address. + * @param amount - The transfer amount in minimal units. + * @returns The encoded calldata. + */ +function buildErc20TransferData(to: string, amount: bigint): Hex { + const iface = new Interface(ERC20_ABI); + return iface.encodeFunctionData('transfer', [to, amount.toString()]) as Hex; +} + +/** + * Encode a teller `deposit`. + * + * @param musdAddress - The deposit asset (mUSD) address. + * @param amount - The deposit amount in minimal units. + * @param minimumMint - The minimum acceptable share amount. + * @returns The encoded calldata. + */ +function buildDepositData( + musdAddress: string, + amount: bigint, + minimumMint: bigint, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('deposit', [ + musdAddress, + amount.toString(), + minimumMint.toString(), + ZERO_ADDRESS, + ]) as Hex; +} + +/** + * Single source of truth for the deposit asset so both calldata encoding + * (`buildMoneyAccountDepositBatch`) and Pay's `requiredAssets` agree. + * + * @param chainId - The chain ID to get the deposit asset address for. + * @returns The deposit asset address for the given chain ID. + */ +export function getMoneyAccountDepositAssetAddress(chainId: Hex): Hex { + const musdAddress = MUSD_TOKEN_ADDRESS_BY_CHAIN[chainId]; + if (!musdAddress) { + throw new Error(`mUSD not deployed on chain ${chainId}`); + } + return musdAddress; +} + +export type MoneyAccountDepositBatchResult = MoneyAccountBatchResult< + 'approveTx' | 'depositTx' +>; + +/** + * Build the approve + deposit transaction pair for a Money Account deposit. + * + * 1. Calls `previewDeposit` on the lens contract to get expected vault shares. + * 2. Applies a 0.2% slippage tolerance to derive `minimumMint`. + * 3. Encodes ERC-20 `approve(boringVault, amount)` on the mUSD token. + * 4. Encodes `deposit(mUSD, amount, minimumMint, 0x0)` on the teller contract. + * + * When `amount === 0n` the preview call is skipped: the caller is encoding a + * zero-amount placeholder batch (e.g. initial deposit submission). + * + * @param args - The build arguments. + * @param args.amount - The deposit amount in minimal units. + * @param args.chainId - The chain to deposit on. + * @param args.boringVault - The vault address. + * @param args.tellerAddress - The teller contract address. + * @param args.accountantAddress - The accountant contract address. + * @param args.lensAddress - The lens contract address. + * @param args.provider - The chain's read provider. + * @returns The named approve + deposit transactions. + */ +export async function buildMoneyAccountDepositBatch({ + amount, + chainId, + boringVault, + tellerAddress, + accountantAddress, + lensAddress, + provider, +}: { + amount: bigint; + chainId: Hex; + boringVault: string; + tellerAddress: string; + accountantAddress: string; + lensAddress: string; + provider: Provider; +}): Promise { + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + // Skip the RPC call for zero-amount placeholder batches (e.g. initial deposit submission). + const minimumMint = + amount === 0n + ? 0n + : applySlippage( + await getExpectedDepositShares({ + lensAddress, + boringVault, + accountantAddress, + musdAddress, + amount, + provider, + }), + ); + + const approveData = buildApproveData(boringVault, amount); + const depositData = buildDepositData(musdAddress, amount, minimumMint); + + return { + approveTx: { + params: { + to: musdAddress, + data: approveData, + value: '0x0' as Hex, + }, + type: TransactionType.tokenMethodApprove, + }, + depositTx: { + params: { + to: tellerAddress as Hex, + data: depositData, + value: '0x0' as Hex, + }, + type: TransactionType.moneyAccountDeposit, + }, + }; +} + +// -- Withdrawal helpers ---------------------------------------------------- + +/** + * Read the current vault rate from the accountant contract. + * + * @param args - The read arguments. + * @param args.accountantAddress - The accountant contract address. + * @param args.provider - The chain's read provider. + * @returns The current vault rate. + */ +async function getVaultRate({ + accountantAddress, + provider, +}: { + accountantAddress: string; + provider: Provider; +}): Promise { + const accountant = new Contract(accountantAddress, ACCOUNTANT_ABI, provider); + const rate = await accountant.getRate(); + return BigInt(rate.toString()); +} + +const SHARE_DECIMALS_SCALAR = BigInt(1_000_000); + +/** + * Convert a USD asset amount (6 decimals) to vault shares given a pre-fetched + * rate. Pure arithmetic — no I/O, safe to call directly inside workflows. + * + * Uses ceiling division so the contract's `mulDivDown(shares × rate / ONE_SHARE)` + * always produces `assetsOut >= minimumAssets`. Floor division caused a double- + * truncation bug where `assetsOut` could land 1 unit below `minimumAssets`, + * reverting with `MinimumAssetsNotMet`. + * + * @param amount - The asset amount in minimal units. + * @param rate - The current vault rate. + * @returns The share amount to redeem. + */ +export function getSharesForWithdrawal(amount: bigint, rate: bigint): bigint { + return (amount * SHARE_DECIMALS_SCALAR + rate - 1n) / rate; +} + +/** + * Encode a teller `withdraw`. + * + * @param musdAddress - The withdraw asset (mUSD) address. + * @param shareAmount - The share amount to redeem. + * @param minimumAssets - The minimum acceptable asset amount out. + * @param toAddress - The address receiving the withdrawn assets. + * @returns The encoded calldata. + */ +function buildWithdrawData( + musdAddress: string, + shareAmount: bigint, + minimumAssets: bigint, + toAddress: string, +): Hex { + const iface = new Interface(TELLER_ABI); + return iface.encodeFunctionData('withdraw', [ + musdAddress, + shareAmount.toString(), + minimumAssets.toString(), + toAddress, + ]) as Hex; +} + +export type MoneyAccountWithdrawBatchResult = MoneyAccountBatchResult< + 'withdrawTx' | 'transferTx' +>; + +/** + * Build the two-transaction withdrawal batch for a Money Account withdrawal. + * + * 1. Calls `getRate` on the accountant contract to get the current vault rate. + * 2. Converts the asset amount to vault shares. + * 3. Encodes `withdraw(mUSD, shareAmount, minimumAssets, moneyAccountAddress)` + * on the teller contract — USDC lands on the money account. + * 4. Encodes `transfer(recipient, amount)` on the USDC contract — moves the + * exact requested USDC from the money account to the user's selected EVM + * account. + * + * When `amount === 0n` the rate fetch is skipped: the caller is encoding a + * placeholder batch that MM Pay will re-encode once the user picks an amount. + * + * @param args - The build arguments. + * @param args.amount - The withdrawal amount in minimal units. + * @param args.chainId - The chain to withdraw on. + * @param args.tellerAddress - The teller contract address. + * @param args.accountantAddress - The accountant contract address. + * @param args.moneyAccountAddress - Address of the money account — vault sends USDC here first. + * @param args.recipient - Address of the user's selected EVM account — receives the USDC transfer. + * @param args.provider - The chain's read provider. + * @returns The named withdraw + transfer transactions. + */ +export async function buildMoneyAccountWithdrawBatch({ + amount, + chainId, + tellerAddress, + accountantAddress, + moneyAccountAddress, + recipient, + provider, +}: { + amount: bigint; + chainId: Hex; + tellerAddress: Hex; + accountantAddress: Hex; + moneyAccountAddress: Hex; + recipient: Hex; + provider: Provider; +}): Promise { + const musdAddress = getMoneyAccountDepositAssetAddress(chainId); + + const shareAmount = + amount === BigInt(0) + ? BigInt(0) + : getSharesForWithdrawal( + amount, + await getVaultRate({ accountantAddress, provider }), + ); + // Allow 1-unit slippage on minimumAssets as defense-in-depth against + // rounding: the contract's mulDivDown can truncate assetsOut by up to + // 1 unit relative to the requested amount. This tolerance is safe + // because ceiling division in getSharesForWithdrawal already guarantees + // assetsOut >= amount; the 1-unit slack here is a second line of + // defense, not a standalone fix. The subsequent ERC-20 transfer uses + // the original `amount`, so the tolerance does not affect how much the + // user receives — it only prevents a spurious revert from the teller's + // MinimumAssetsNotMet check. + const minimumAssets = amount > 0n ? amount - 1n : 0n; + const withdrawData = buildWithdrawData( + musdAddress, + shareAmount, + minimumAssets, + moneyAccountAddress, + ); + const transferData = buildErc20TransferData(recipient, amount); + + return { + withdrawTx: { + params: { + to: tellerAddress, + data: withdrawData, + value: '0x0' as Hex, + }, + type: TransactionType.moneyAccountWithdraw, + }, + transferTx: { + params: { + to: musdAddress, + data: transferData, + value: '0x0' as Hex, + }, + type: TransactionType.tokenMethodTransfer, + }, + }; +} diff --git a/packages/money-account-utils/src/moneyActivity.test.ts b/packages/money-account-utils/src/moneyActivity.test.ts new file mode 100644 index 0000000000..a516bc5fa6 --- /dev/null +++ b/packages/money-account-utils/src/moneyActivity.test.ts @@ -0,0 +1,215 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import type { AccountsApiActivity } from './moneyActivity'; +import { + accountsApiItem, + buildMoneyActivityBuckets, + mergeMoneyActivity, + MoneyActivityFilter, + onchainItem, +} from './moneyActivity'; + +const onchainTx = (id: string, time: number, hash?: Hex): TransactionMeta => + ({ id, time, hash }) as TransactionMeta; + +const cardTx = (hash: Hex, time: number): AccountsApiActivity => ({ + kind: 'card', + hash, + time, + chainId: '0x8f', + token: { address: '0xusdc' as Hex, symbol: 'USDC', decimals: 6 }, + amount: '1000000', + paidTo: '0xsettlement' as Hex, +}); + +const cashbackTx = (hash: Hex, time: number): AccountsApiActivity => ({ + kind: 'cashback', + hash, + time, + chainId: '0x8f', + token: { address: '0xmusd' as Hex, symbol: 'mUSD', decimals: 6 }, + amount: '300000', + receivedFrom: '0xrewarder' as Hex, +}); + +const refundTx = (hash: Hex, time: number): AccountsApiActivity => ({ + kind: 'refund', + hash, + time, + chainId: '0x8f', + token: { address: '0xmusd' as Hex, symbol: 'mUSD', decimals: 6 }, + amount: '10000000', + receivedFrom: '0xsettlement' as Hex, +}); + +describe('onchainItem', () => { + it('tags the row with its source and carries id/time through', () => { + expect(onchainItem(onchainTx('a', 42))).toStrictEqual({ + kind: 'onchain', + id: 'a', + time: 42, + tx: onchainTx('a', 42), + }); + }); + + it('defaults a missing time to 0', () => { + const tx = { id: 'a' } as TransactionMeta; + expect(onchainItem(tx).time).toBe(0); + }); +}); + +describe('accountsApiItem', () => { + it('keys the row on its transaction hash', () => { + const tx = cardTx('0xabc' as Hex, 42); + expect(accountsApiItem(tx)).toStrictEqual({ + kind: 'accountsApi', + id: '0xabc', + time: 42, + tx, + }); + }); +}); + +describe('mergeMoneyActivity', () => { + it('merges both sources, tags by source, and sorts time-descending', () => { + const onchain = [onchainTx('a', 100), onchainTx('b', 300)]; + const api = [cardTx('0xcard' as Hex, 200)]; + + const items = mergeMoneyActivity(onchain, api); + + expect(items.map((i) => [i.kind, i.id, i.time])).toStrictEqual([ + ['onchain', 'b', 300], + ['accountsApi', '0xcard', 200], + ['onchain', 'a', 100], + ]); + }); + + it('drops an on-chain row that collides with an API hash (double-count guard)', () => { + const shared = '0xAbC123' as Hex; + const onchain = [onchainTx('dup', 100, shared)]; + const api = [cardTx('0xabc123' as Hex, 100)]; + + const items = mergeMoneyActivity(onchain, api); + + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ kind: 'accountsApi', id: '0xabc123' }); + }); + + it('returns an empty list when both sources are empty', () => { + expect(mergeMoneyActivity([], [])).toStrictEqual([]); + }); + + it('orders rows sharing a timestamp deterministically by id', () => { + // A spend and its cashback can settle in the same second; the tie must + // resolve the same way regardless of input order. + const onchain = [onchainTx('zzz', 100)]; + const api = [cardTx('0xccc' as Hex, 100), cashbackTx('0xaaa' as Hex, 100)]; + + const forward = mergeMoneyActivity(onchain, api).map((i) => i.id); + const reversed = mergeMoneyActivity([...onchain], [...api].reverse()).map( + (i) => i.id, + ); + + expect(forward).toStrictEqual(['0xaaa', '0xccc', 'zzz']); + expect(reversed).toStrictEqual(forward); + }); +}); + +describe('buildMoneyActivityBuckets', () => { + const onchain = { + all: [onchainTx('all', 50)], + deposits: [onchainTx('dep', 40)], + transfers: [onchainTx('xfer', 30)], + }; + const card = cardTx('0xcard' as Hex, 200); + const cashback = cashbackTx('0xback' as Hex, 300); + const refund = refundTx('0xrefund' as Hex, 250); + + it('routes card spends to Transfers and cashback to Deposits; both into All', () => { + const buckets = buildMoneyActivityBuckets(onchain, [card, cashback]); + + const ids = (filter: MoneyActivityFilter): string[] => + buckets[filter].map((item) => item.id); + + // All contains both API rows. + expect(ids(MoneyActivityFilter.All)).toStrictEqual( + expect.arrayContaining(['0xback', '0xcard', 'all']), + ); + // Deposits: cashback inflow, not the card spend. + expect(ids(MoneyActivityFilter.Deposits)).toContain('0xback'); + expect(ids(MoneyActivityFilter.Deposits)).not.toContain('0xcard'); + // Transfers: card outflow, not the cashback. + expect(ids(MoneyActivityFilter.Transfers)).toContain('0xcard'); + expect(ids(MoneyActivityFilter.Transfers)).not.toContain('0xback'); + }); + + it('groups all card activity (spend, cashback, refund) into Purchases without on-chain rows', () => { + const buckets = buildMoneyActivityBuckets(onchain, [ + card, + cashback, + refund, + ]); + + const ids = (filter: MoneyActivityFilter): string[] => + buckets[filter].map((item) => item.id); + + // Purchases: every card-related row, time-descending, and no on-chain rows. + expect(ids(MoneyActivityFilter.Purchases)).toStrictEqual([ + '0xback', + '0xrefund', + '0xcard', + ]); + // Refund flows into All but leaves Deposits/Sends untouched (additive). + expect(ids(MoneyActivityFilter.All)).toContain('0xrefund'); + expect(ids(MoneyActivityFilter.Deposits)).not.toContain('0xrefund'); + expect(ids(MoneyActivityFilter.Transfers)).not.toContain('0xrefund'); + }); + + it('keeps each bucket time-descending', () => { + const buckets = buildMoneyActivityBuckets(onchain, [card, cashback]); + const times = buckets[MoneyActivityFilter.All].map((i) => i.time); + expect(times).toStrictEqual([...times].sort((a, b) => b - a)); + }); + + it('withholds rows older than the watermark from every bucket', () => { + // Watermark at 100: the on-chain rows (50/40/30) are below it and may have + // un-fetched API rows above them, so they must not render yet. + const buckets = buildMoneyActivityBuckets(onchain, [card, cashback], 100); + + expect(buckets[MoneyActivityFilter.All].map((i) => i.id)).toStrictEqual([ + '0xback', + '0xcard', + ]); + expect( + buckets[MoneyActivityFilter.Deposits].map((i) => i.id), + ).toStrictEqual(['0xback']); + expect( + buckets[MoneyActivityFilter.Transfers].map((i) => i.id), + ).toStrictEqual(['0xcard']); + }); + + it('withholds rows at exactly the watermark (second-resolution ties)', () => { + // The card row sits exactly on the watermark (200). The next un-fetched + // page can open with rows at the same timestamp whose id tiebreak would + // sort them above this one, so it must not render yet — from any bucket, + // including the API-only Purchases tab. + const buckets = buildMoneyActivityBuckets(onchain, [card, cashback], 200); + + expect(buckets[MoneyActivityFilter.All].map((i) => i.id)).toStrictEqual([ + '0xback', + ]); + expect( + buckets[MoneyActivityFilter.Purchases].map((i) => i.id), + ).toStrictEqual(['0xback']); + }); + + it('shows everything when the watermark is -Infinity (fully loaded)', () => { + const buckets = buildMoneyActivityBuckets( + onchain, + [card, cashback], + Number.NEGATIVE_INFINITY, + ); + expect(buckets[MoneyActivityFilter.All]).toHaveLength(3); + }); +}); diff --git a/packages/money-account-utils/src/moneyActivity.ts b/packages/money-account-utils/src/moneyActivity.ts new file mode 100644 index 0000000000..56c67d0eb2 --- /dev/null +++ b/packages/money-account-utils/src/moneyActivity.ts @@ -0,0 +1,203 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +/** + * The filter tabs of the Money activity list. + */ +export enum MoneyActivityFilter { + All = 'all', + Deposits = 'deposits', + Transfers = 'transfers', + Purchases = 'purchases', +} + +/** + * When set on mock or enriched {@link TransactionMeta}, overrides the default + * title derived from the transaction type. + */ +export type MoneyActivityTitleKey = + | 'deposited' + | 'received' + | 'card_transaction' + | 'converted' + | 'sent'; + +/** + * {@link TransactionMeta} plus optional Money activity presentation fields. + */ +export type MoneyActivityTransactionMeta = TransactionMeta & { + moneySubtitle?: string; + moneyActivityTitleKey?: MoneyActivityTitleKey; +}; + +/** + * Card spends and mUSD-back from the Accounts API. These aren't created in + * the client, so they never reach the local `TransactionController` — they + * come from the MetaMask Accounts API. + */ +type AccountsApiSettlement = { + /** On-chain tx hash. Stable identity for the activity row. */ + hash: Hex; + /** Settlement time, epoch ms (parsed from the API's ISO timestamp). */ + time: number; + chainId: Hex; + /** The settlement token. */ + token: { + address: Hex; + symbol: string; + decimals: number; + }; + /** Raw, minimal-unit amount of the settlement transfer. */ + amount: string; +}; + +export type AccountsApiActivity = + | (AccountsApiSettlement & { + kind: 'card'; + paidTo: Hex; + }) + | (AccountsApiSettlement & { + kind: 'cashback'; + receivedFrom: Hex; + }) + | (AccountsApiSettlement & { + kind: 'refund'; + receivedFrom: Hex; + }); + +/** + * One row in the Money activity list, tagged by source. + */ +export type MoneyActivityItem = + | { kind: 'onchain'; id: string; time: number; tx: TransactionMeta } + | { kind: 'accountsApi'; id: string; time: number; tx: AccountsApiActivity }; + +/** + * Wrap a local on-chain transaction as an activity row. + * + * @param tx - The transaction to wrap. + * @returns The source-tagged activity row. + */ +export const onchainItem = (tx: TransactionMeta): MoneyActivityItem => ({ + kind: 'onchain', + id: tx.id, + time: tx.time ?? 0, + tx, +}); + +/** + * Wrap an Accounts-API activity entry as an activity row. + * + * @param tx - The activity entry to wrap. + * @returns The source-tagged activity row. + */ +export const accountsApiItem = ( + tx: AccountsApiActivity, +): MoneyActivityItem => ({ + kind: 'accountsApi', + id: tx.hash, + time: tx.time, + tx, +}); + +/** + * The list shown for each activity filter tab. + */ +export type MoneyActivityBuckets = Record< + MoneyActivityFilter, + MoneyActivityItem[] +>; + +/** + * Withhold merged rows at or below the Accounts-API watermark: below it there + * may be un-fetched API rows that belong higher in the list, so showing those + * rows now would let older activity pop in above them on the next page load. + * The gate is strict (`>`): timestamps are second-resolution, so the next + * un-fetched page can open with rows at exactly the watermark whose id + * tiebreak would sort them above an already-rendered row. + * + * @param items - The merged activity rows. + * @param watermark - The pagination watermark in epoch ms. + * @returns The rows that are safe to display. + */ +function safeItems( + items: MoneyActivityItem[], + watermark: number, +): MoneyActivityItem[] { + if (watermark === Number.NEGATIVE_INFINITY) { + return items; + } + return items.filter((item) => item.time > watermark); +} + +/** + * Merge local on-chain Money transactions with Accounts-API activity (card + * spends and cashback) into a single source-tagged, time-descending list. + * + * @param onchainTransactions - Local on-chain Money transactions. + * @param apiActivity - Parsed Accounts-API activity rows. + * @returns The merged, time-descending activity list. + */ +export function mergeMoneyActivity( + onchainTransactions: TransactionMeta[], + apiActivity: AccountsApiActivity[], +): MoneyActivityItem[] { + const apiHashes = new Set(apiActivity.map((a) => a.hash.toLowerCase())); + const onchain = onchainTransactions + // we ignore any on chain data that exists in the accounts API response. + .filter((tx) => !(tx.hash && apiHashes.has(tx.hash.toLowerCase()))) + .map(onchainItem); + // Time-descending, with `id` as a stable tiebreak so rows sharing a timestamp + // (e.g. a spend and its cashback in the same second) keep a deterministic + // order across renders/refetches and across the two merged sources. + return [...onchain, ...apiActivity.map(accountsApiItem)].sort( + (a, b) => b.time - a.time || a.id.localeCompare(b.id), + ); +} + +/** + * Build the per-filter-tab activity lists from the two activity sources. + * + * @param onchain - Local on-chain transactions, pre-split per tab. + * @param onchain.all - Transactions for the "All" tab. + * @param onchain.deposits - Transactions for the "Deposits" tab. + * @param onchain.transfers - Transactions for the "Transfers" tab. + * @param apiActivity - Parsed Accounts-API activity rows. + * @param watermark - The pagination watermark in epoch ms; rows at or below + * it are withheld. Defaults to `-Infinity` (everything shown). + * @returns The merged, gated activity list for every filter tab. + */ +export function buildMoneyActivityBuckets( + onchain: { + all: TransactionMeta[]; + deposits: TransactionMeta[]; + transfers: TransactionMeta[]; + }, + apiActivity: AccountsApiActivity[], + watermark: number = Number.NEGATIVE_INFINITY, +): MoneyActivityBuckets { + const cards = apiActivity.filter((a) => a.kind === 'card'); + const cashback = apiActivity.filter((a) => a.kind === 'cashback'); + const refunds = apiActivity.filter((a) => a.kind === 'refund'); + return { + [MoneyActivityFilter.All]: safeItems( + mergeMoneyActivity(onchain.all, apiActivity), + watermark, + ), + [MoneyActivityFilter.Deposits]: safeItems( + mergeMoneyActivity(onchain.deposits, cashback), + watermark, + ), + [MoneyActivityFilter.Transfers]: safeItems( + mergeMoneyActivity(onchain.transfers, cards), + watermark, + ), + // Purchases is API-only, but the strict gate still applies: a fetched row + // at exactly the watermark may have same-timestamp siblings on the next + // page that would sort above it, so it's withheld like everything else. + [MoneyActivityFilter.Purchases]: safeItems( + mergeMoneyActivity([], [...cards, ...cashback, ...refunds]), + watermark, + ), + }; +} diff --git a/packages/money-account-utils/src/moneyEvents.test.ts b/packages/money-account-utils/src/moneyEvents.test.ts new file mode 100644 index 0000000000..7e78026612 --- /dev/null +++ b/packages/money-account-utils/src/moneyEvents.test.ts @@ -0,0 +1,137 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { + TransactionStatus, + TransactionType, +} from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; + +import { + BOTTOM_SHEET_NAMES, + deriveMoneyActivityTransactionProperties, + REDIRECT_TARGETS_TYPES, + resolveRedirectTargetType, + SCREEN_NAMES, + withRedirectType, +} from './moneyEvents'; + +describe('analytics vocabulary', () => { + it('keeps screen and bottom-sheet target names disjoint', () => { + // resolveRedirectTargetType is precedence-ordered; a duplicated value + // would be silently misclassified. + const screens = Object.values(SCREEN_NAMES); + const sheets = new Set(Object.values(BOTTOM_SHEET_NAMES)); + expect(screens.filter((name) => sheets.has(name))).toStrictEqual([]); + }); +}); + +describe('resolveRedirectTargetType', () => { + it('classifies a screen target', () => { + expect(resolveRedirectTargetType(SCREEN_NAMES.MONEY_HOME)).toBe( + REDIRECT_TARGETS_TYPES.SCREEN, + ); + }); + + it('classifies a bottom sheet target', () => { + expect( + resolveRedirectTargetType(BOTTOM_SHEET_NAMES.MONEY_ADD_MONEY_SHEET), + ).toBe(REDIRECT_TARGETS_TYPES.BOTTOM_SHEET); + }); + + it('classifies a client URL target when provided', () => { + expect( + resolveRedirectTargetType('https://metamask.io/money', [ + 'https://metamask.io/money', + ]), + ).toBe(REDIRECT_TARGETS_TYPES.EXTERNAL_BROWSER); + }); + + it('returns undefined for an unknown target', () => { + expect(resolveRedirectTargetType('not-a-target')).toBeUndefined(); + expect( + resolveRedirectTargetType('https://unknown.example'), + ).toBeUndefined(); + }); +}); + +describe('withRedirectType', () => { + it('adds redirect_target_type derived from the target', () => { + expect( + withRedirectType({ redirect_target: SCREEN_NAMES.MONEY_HOME }), + ).toStrictEqual({ + redirect_target: SCREEN_NAMES.MONEY_HOME, + redirect_target_type: REDIRECT_TARGETS_TYPES.SCREEN, + }); + }); + + it('classifies URL targets using the provided list', () => { + const url = 'https://metamask.io/money'; + expect(withRedirectType({ redirect_target: url }, [url])).toStrictEqual({ + redirect_target: url, + redirect_target_type: REDIRECT_TARGETS_TYPES.EXTERNAL_BROWSER, + }); + }); + + it('is a no-op when no target is present (e.g. tooltip clicks)', () => { + const props = { tooltip_name: 'apy' }; + expect(withRedirectType(props)).toBe(props); + }); +}); + +describe('deriveMoneyActivityTransactionProperties', () => { + const baseTx = { + id: 'tx-1', + chainId: '0x8f' as Hex, + status: TransactionStatus.confirmed, + } as unknown as TransactionMeta; + + it('resolves a nested money deposit and snake_cases the type', () => { + const tx = { + ...baseTx, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.moneyAccountDeposit }], + } as unknown as TransactionMeta; + + expect(deriveMoneyActivityTransactionProperties(tx)).toStrictEqual({ + transaction_type: 'money_account_deposit', + transaction_status: TransactionStatus.confirmed, + chain_id_source: '0x8f', + chain_id_destination: '0x8f', + }); + }); + + it('resolves a nested money withdraw', () => { + const tx = { + ...baseTx, + type: TransactionType.batch, + nestedTransactions: [{ type: TransactionType.moneyAccountWithdraw }], + } as unknown as TransactionMeta; + + expect(deriveMoneyActivityTransactionProperties(tx).transaction_type).toBe( + 'money_account_withdraw', + ); + }); + + it('falls back to the transaction type for non-money transactions', () => { + const tx = { + ...baseTx, + type: TransactionType.simpleSend, + } as unknown as TransactionMeta; + + expect(deriveMoneyActivityTransactionProperties(tx).transaction_type).toBe( + 'simple_send', + ); + }); + + it('splits source and destination chain ids for a MetaMask Pay deposit', () => { + const tx = { + ...baseTx, + chainId: '0x8f' as Hex, + type: TransactionType.moneyAccountDeposit, + metamaskPay: { chainId: '0x1' as Hex, isPostQuote: false }, + } as unknown as TransactionMeta; + + const derived = deriveMoneyActivityTransactionProperties(tx); + expect(derived.chain_id_source).toBe('0x1'); + expect(derived.chain_id_destination).toBe('0x8f'); + }); +}); diff --git a/packages/money-account-utils/src/moneyEvents.ts b/packages/money-account-utils/src/moneyEvents.ts new file mode 100644 index 0000000000..65f327373e --- /dev/null +++ b/packages/money-account-utils/src/moneyEvents.ts @@ -0,0 +1,268 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// The enums and payload properties in this file are an analytics wire format: +// snake_case names are the shared vocabulary emitted by every client. + +import type { TransactionMeta } from '@metamask/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; +import type { TransactionStatus } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { snakeCase } from 'lodash'; + +import { + getMMPayChainIds, + isMoneyDepositTx, + isMoneyWithdrawTx, +} from './moneyTransactionGuards'; + +/** + * Screen names for the Money account feature's analytics vocabulary. + */ +export enum SCREEN_NAMES { + WALLET_HOME = 'wallet_home', + MONEY_HOME = 'money_home', + MONEY_ONBOARDING = 'money_onboarding', + CARD_HOME = 'card_home', + MONEY_DEPOSIT = 'money_deposit', + // Used for Money account withdrawals + MONEY_TRANSFER = 'money_transfer', + MONEY_HOW_IT_WORKS = 'money_how_it_works', + MONEY_ACTIVITY = 'money_activity', + MONEY_ACTIVITY_DETAILS = 'money_activity_details', + MONEY_POTENTIAL_EARNINGS = 'money_potential_earnings', + MONEY_FIRST_TIME_DEPOSIT = 'money_first_time_deposit', +} + +export enum BOTTOM_SHEET_NAMES { + MONEY_ADD_MONEY_SHEET = 'money_add_money_sheet', + MONEY_TRANSFER_MONEY_SHEET = 'money_transfer_money_sheet', + CARD_AUTH_SHEET = 'card_auth_sheet', + CARD_LINK_SHEET = 'card_link_sheet', + MONEY_APY_INFO_SHEET = 'money_apy_info_sheet', + MONEY_EARNINGS_INFO_SHEET = 'money_earnings_info_sheet', + MONEY_EARN_CRYPTO_INFO_SHEET = 'money_earn_crypto_info_sheet', + MONEY_MORE_SHEET = 'money_more_sheet', + MONEY_BALANCE_INFO_SHEET = 'money_balance_info_sheet', + MONEY_GEO_BLOCK_SHEET = 'money_geo_block_sheet', + MONEY_DEEPLINK_MODAL = 'money_deeplink_modal', +} + +export enum REDIRECT_TARGETS_TYPES { + SCREEN = 'screen', + BOTTOM_SHEET = 'bottom_sheet', + EXTERNAL_BROWSER = 'external_browser', +} + +export enum MONEY_SURFACE_TYPES { + SCREEN = 'screen', + BOTTOM_SHEET = 'bottom_sheet', + COMPONENT = 'component', +} + +export enum COMPONENT_NAMES { + // — Section Headers — + MONEY_POTENTIAL_EARNINGS_SECTION_HEADER = 'money_potential_earnings_section_header', + MONEY_ACTIVITY_SECTION_HEADER = 'money_activity_section_header', + MONEY_HOW_IT_WORKS_SECTION_HEADER = 'money_how_it_works_section_header', + MONEY_CARD_SECTION_HEADER = 'money_card_section_header', + + // — Onboarding — + RIVE_ONBOARDING_STEPPER = 'rive_onboarding_stepper', + /** The Stepper Card component on Money Home screen (add funds, get/link card). */ + MONEY_ONBOARDING_CARD = 'money_onboarding_card', + + // — Earnings — + MONEY_ESTIMATED_EARNINGS_SECTION = 'money_estimated_earnings_section', + MONEY_POTENTIAL_EARNINGS_SECTION = 'money_potential_earnings_section', + MONEY_POTENTIAL_EARNINGS_SECTION_TOKEN_ROW = 'money_potential_earnings_section_token_row', + MONEY_POTENTIAL_EARNINGS_TOKEN_ROW = 'money_potential_earnings_token_row', + + // — Activity — + MONEY_ACTIVITY_SECTION = 'money_activity_section', + MONEY_ACTIVITY_LIST_ITEM = 'money_activity_list_item', + + // — Activity Filter Buttons — + MONEY_ACTIVITY_FILTER_ALL = 'money_activity_filter_all', + MONEY_ACTIVITY_FILTER_DEPOSITS = 'money_activity_filter_deposits', + MONEY_ACTIVITY_FILTER_TRANSFERS = 'money_activity_filter_transfers', + MONEY_ACTIVITY_FILTER_PURCHASES = 'money_activity_filter_purchases', + + // — Condensed Info Cards — + MONEY_CONDENSED_INFO_CARDS_HOW_IT_WORKS = 'money_condensed_info_cards_how_it_works', + MONEY_CONDENSED_INFO_CARDS_MUSD = 'money_condensed_info_cards_musd', + MONEY_CONDENSED_INFO_CARDS_WHAT_YOU_GET = 'money_condensed_info_cards_what_you_get', + + // — Add Money Sheet — + MONEY_ADD_MONEY_SHEET_CONVERT_CRYPTO = 'money_add_money_sheet_convert_crypto', + MONEY_ADD_MONEY_SHEET_DEPOSIT_FUNDS = 'money_add_money_sheet_deposit_funds', + MONEY_ADD_MONEY_SHEET_MOVE_MUSD = 'money_add_money_sheet_move_musd', + + // — Transfer Money Sheet — + MONEY_TRANSFER_MONEY_SHEET_BETWEEN_ACCOUNTS = 'money_transfer_money_sheet_between_accounts', + MONEY_TRANSFER_MONEY_SHEET_PERPS_ACCOUNT = 'money_transfer_money_sheet_perps_account', + MONEY_TRANSFER_MONEY_SHEET_PREDICTIONS_ACCOUNT = 'money_transfer_money_sheet_predictions_account', + + // — More Sheet — + MONEY_MORE_SHEET_HOW_IT_WORKS = 'money_more_sheet_how_it_works', + MONEY_MORE_SHEET_WHAT_YOU_GET = 'money_more_sheet_what_you_get', + MONEY_MORE_SHEET_CONTACT_SUPPORT = 'money_more_sheet_contact_support', + + // — Miscellaneous — + MONEY_WHAT_YOU_GET_SECTION = 'money_what_you_get_section', + MONEY_MUSD_TOKEN_SECTION = 'money_musd_token_row_section', + MONEY_BALANCE_CARD = 'money_balance_card', + MONEY_BALANCE_SUMMARY = 'money_balance_summary', + MONEY_HOME_TAB = 'money_home_tab', + MONEY_ACTION_BUTTON_ROW = 'money_action_button_row', + MONEY_FOOTER = 'money_footer', + MONEY_CONVERT_CRYPTO_BUTTON = 'money_convert_crypto_button', + MONEY_MORE = 'money_more', + + // — How It Works / FAQ — + FAQ_ITEM = 'money_faq_item', +} + +/** + * The intent of the button that was clicked. + * Identifies the intended action of the button independent of the button's + * label or component it lives in. + */ +export enum MONEY_BUTTON_INTENTS { + ADD_MONEY = 'add_money', + GET_STARTED = 'get_started', + GO_TO_MONEY_HOME = 'go_to_money_home', + GO_TO_MONEY_ONBOARDING = 'go_to_money_onboarding', + TRANSFER_MONEY = 'transfer_money', + LEARN_MORE = 'learn_more', + OPEN_MORE_MENU = 'open_more_menu', + VIEW_ALL = 'view_all', + FILTER = 'filter', + CARD_HOME = 'card_home', + CARD_FEES = 'card_fees', +} + +export enum MONEY_BUTTON_TYPES { + TEXT = 'text', + ICON = 'icon', +} + +export enum MONEY_TOOLTIP_NAMES { + MONEY_BALANCE = 'money_balance', + ESTIMATED_EARNINGS = 'estimated_earnings', + EARN_ON_YOUR_CRYPTO = 'earn_on_your_crypto', + APY = 'apy', +} + +export enum MONEY_TOOLTIP_TYPES { + INFO = 'info', +} + +export enum MONEY_ONBOARDING_STEP_ACTIONS { + // Generic actions + VIEWED = 'viewed', + SKIPPED = 'skipped', + EXITED = 'exited', + COMPLETED = 'completed', + + // Transaction actions + DEPOSIT_INITIATED = 'deposit_initiated', + + // Card actions + GET_CARD = 'get_card', + LINK_CARD = 'link_card', +} + +const SCREEN_TARGETS = new Set(Object.values(SCREEN_NAMES)); +const BOTTOM_SHEET_TARGETS = new Set(Object.values(BOTTOM_SHEET_NAMES)); + +/** + * Resolve a redirect target's type from the target itself. A target's type is + * a fact about the target, not something callers should restate, so this is + * the single source of truth. + * + * Resolution is precedence-ordered, so a value duplicated across + * SCREEN_NAMES, BOTTOM_SHEET_NAMES, or the client's URL targets would be + * silently misclassified. Keep these groups disjoint when adding targets. + * + * @param target - The redirect target (a screen, bottom sheet, or URL). + * @param urlTargets - The client's known external URL targets. + * @returns The target's type, or undefined for an unknown target (clients may + * log this — it means a target was added without a category). + */ +export function resolveRedirectTargetType( + target: string, + urlTargets: readonly string[] = [], +): REDIRECT_TARGETS_TYPES | undefined { + if (SCREEN_TARGETS.has(target)) { + return REDIRECT_TARGETS_TYPES.SCREEN; + } + if (BOTTOM_SHEET_TARGETS.has(target)) { + return REDIRECT_TARGETS_TYPES.BOTTOM_SHEET; + } + if (urlTargets.includes(target)) { + return REDIRECT_TARGETS_TYPES.EXTERNAL_BROWSER; + } + return undefined; +} + +/** + * Derive `redirect_target_type` from `redirect_target` so callers only state + * the target once and the two can never contradict. No-op when no target is + * present (e.g. tooltip clicks). + * + * @param props - The event properties, possibly carrying a redirect target. + * @param urlTargets - The client's known external URL targets. + * @returns The properties, with `redirect_target_type` added when a target is + * present. + */ +export function withRedirectType( + props: T, + urlTargets: readonly string[] = [], +): T | (T & { redirect_target_type: REDIRECT_TARGETS_TYPES | undefined }) { + return props.redirect_target + ? { + ...props, + redirect_target_type: resolveRedirectTargetType( + props.redirect_target, + urlTargets, + ), + } + : props; +} + +/** + * Transaction-derived properties of a Money activity click event. + */ +export type MoneyActivityTransactionProperties = { + transaction_type: string; + transaction_status: TransactionStatus; + chain_id_source: Hex | undefined; + chain_id_destination: Hex | undefined; +}; + +/** + * Derive the transaction-shaped analytics properties for a Money activity + * row click: the effective (nested-aware) transaction type in snake_case, + * the status, and the MetaMask Pay source/destination chain ids. + * + * @param transaction - The clicked activity row's transaction. + * @returns The derived event properties. + */ +export function deriveMoneyActivityTransactionProperties( + transaction: TransactionMeta, +): MoneyActivityTransactionProperties { + const { sourceChainId, destinationChainId } = getMMPayChainIds(transaction); + + let nestedTxType = transaction.type; + if (isMoneyDepositTx(transaction)) { + nestedTxType = TransactionType.moneyAccountDeposit; + } else if (isMoneyWithdrawTx(transaction)) { + nestedTxType = TransactionType.moneyAccountWithdraw; + } + + return { + transaction_type: snakeCase(nestedTxType), + transaction_status: transaction.status, + chain_id_source: sourceChainId, + chain_id_destination: destinationChainId, + }; +} diff --git a/packages/money-account-utils/src/moneyEvents.types.ts b/packages/money-account-utils/src/moneyEvents.types.ts new file mode 100644 index 0000000000..c7ad1ee1b0 --- /dev/null +++ b/packages/money-account-utils/src/moneyEvents.types.ts @@ -0,0 +1,155 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +// Payload properties are an analytics wire format: snake_case names are the +// shared vocabulary emitted by every client. + +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { + BOTTOM_SHEET_NAMES, + COMPONENT_NAMES, + MONEY_BUTTON_INTENTS, + MONEY_BUTTON_TYPES, + MONEY_ONBOARDING_STEP_ACTIONS, + MONEY_TOOLTIP_NAMES, + MONEY_TOOLTIP_TYPES, + SCREEN_NAMES, +} from './moneyEvents'; + +/** + * A redirect destination: a screen, a bottom sheet, or a client URL. The + * `string & Record` arm admits client-defined URL constants + * without collapsing the union (which would lose enum autocomplete). + */ +export type MoneyRedirectTarget = + | SCREEN_NAMES + | BOTTOM_SHEET_NAMES + | (string & Record); + +/** + * Properties for tracking location-based events. + * screen_name: The name of the screen the event occurred on. + * component_name: The name of the component the event occurred on. + * bottom_sheet_name: The name of the bottom sheet the event occurred on. + */ +export type MoneyLocationEventProperties = { + screen_name: SCREEN_NAMES; + bottom_sheet_name: BOTTOM_SHEET_NAMES; + component_name: COMPONENT_NAMES; +}; + +export type MoneyCardEventProperties = { + is_card_holder: boolean; + is_card_linked_to_money_account: boolean; +}; + +type MoneyFundedEventProperties = { + is_account_funded: boolean | null; + is_money_balance_loading: boolean; +}; + +export type MoneyRedirectEventProperties = { + redirect_target: MoneyRedirectTarget; +}; + +export type MoneySurfaceClickedEventProperties = MoneyRedirectEventProperties & + Partial; + +export type MoneyTokenSurfaceClickedEventProperties = + MoneySurfaceClickedEventProperties & MoneyTokenRowEventProperties; + +export type MoneyActivitySurfaceClickedEventProperties = + MoneySurfaceClickedEventProperties & { + transaction: TransactionMeta; + }; + +/** + * Base properties for all Money events. + */ +export type MoneyBaseEventProperties = Partial & + MoneyCardEventProperties & + MoneyFundedEventProperties; + +type MoneyTokenRowEventProperties = { + token_symbol: string; + token_position_in_list: number; + token_chain_id: string; + tokens_in_list: number; +}; + +export type MoneyTokenRowButtonClickedEventProperties = + MoneyButtonClickedEventProperties & MoneyTokenRowEventProperties; + +export type MoneyTokenRowButtonClickedInputProperties = + MoneyButtonClickedInputProperties & MoneyTokenRowEventProperties; + +export type MoneyButtonClickedEventProperties = + | ({ + button_type: MONEY_BUTTON_TYPES.TEXT; + } & MoneyTextButtonClickedEventProperties) + | ({ + button_type: MONEY_BUTTON_TYPES.ICON; + } & MoneyIconButtonClickedEventProperties); + +/** + * Properties shared by every button-clicked variant, regardless of label. + */ +type MoneyButtonClickedBaseProperties = Partial< + Pick +> & + Partial & { + button_intent: MONEY_BUTTON_INTENTS; + /** 1-based index of the button position in a button row. */ + button_position?: number; + /** Number of buttons in the button row. */ + button_row_button_count?: number; + }; + +/** The resolved label pair as sent in the event payload. */ +type MoneyButtonLabel = { + label_en: string; + label_localized: string; +}; + +export type MoneyTextButtonClickedEventProperties = + MoneyButtonClickedBaseProperties & MoneyButtonLabel; + +type MoneyIconButtonClickedEventProperties = MoneyButtonClickedBaseProperties; + +/** + * Callers may either supply the resolved `label_en`/`label_localized` pair + * (for dynamically computed labels) or a single `label_key` (an i18n key) that + * the client resolves into both. The two forms are mutually exclusive so a + * label and its key can never contradict. + */ +type MoneyButtonLabelInput = + | (MoneyButtonLabel & { label_key?: never }) + | { label_key: string; label_en?: never; label_localized?: never }; + +type MoneyTextButtonClickedInputProperties = MoneyButtonClickedBaseProperties & + MoneyButtonLabelInput; + +/** + * Input accepted by `trackButtonClicked`. Differs from the payload type only + * in that the TEXT variant additionally accepts `label_key`. + */ +export type MoneyButtonClickedInputProperties = + | ({ + button_type: MONEY_BUTTON_TYPES.TEXT; + } & MoneyTextButtonClickedInputProperties) + | ({ + button_type: MONEY_BUTTON_TYPES.ICON; + } & MoneyIconButtonClickedEventProperties); + +export type MoneyOnboardingEventProperties = + Partial & { + step: number; + step_title: string; + step_action?: MONEY_ONBOARDING_STEP_ACTIONS; + total_steps: number; + }; + +export type MoneyTooltipClickedEventProperties = + Partial & { + tooltip_name: MONEY_TOOLTIP_NAMES; + tooltip_type: MONEY_TOOLTIP_TYPES; + }; 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 9041deb7d0..5ef1850831 100644 --- a/teams.json +++ b/teams.json @@ -91,6 +91,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 25a09e3f32..c5cf6a6702 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -175,6 +175,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" }, diff --git a/tsconfig.json b/tsconfig.json index 0dba10ed9a..f728601d80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -167,6 +167,9 @@ { "path": "./packages/money-account-upgrade-controller" }, + { + "path": "./packages/money-account-utils" + }, { "path": "./packages/multichain-account-service" }, @@ -289,5 +292,10 @@ } ], "files": [], - "include": ["./docs", "./types", "./scripts", "./knip.config.ts"] + "include": [ + "./docs", + "./types", + "./scripts", + "./knip.config.ts" + ] } diff --git a/yarn.lock b/yarn.lock index ba1a636241..fc2ed40680 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7565,6 +7565,31 @@ __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: + "@ethersproject/abi": "npm:^5.7.0" + "@ethersproject/contracts": "npm:^5.7.0" + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/transaction-controller": "npm:^68.3.0" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + "@types/lodash": "npm:^4.14.191" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + lodash: "npm:^4.17.21" + 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:^12.0.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"