diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 20163ad18ae..97852002a37 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -30,6 +30,7 @@ ## Confirmations Team /packages/address-book-controller @MetaMask/confirmations /packages/approval-controller @MetaMask/confirmations +/packages/ens-controller @MetaMask/confirmations /packages/gas-fee-controller @MetaMask/confirmations /packages/logging-controller @MetaMask/confirmations /packages/message-manager @MetaMask/confirmations @@ -216,6 +217,9 @@ /packages/money-account-api-data-service/package.json @MetaMask/earn @MetaMask/core-platform /packages/money-account-api-data-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform /packages/money-account-api-data-service/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/ens-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/ens-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/ens-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/package.json @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform /packages/gas-fee-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform diff --git a/README.md b/README.md index c1880364cce..5100e8483a8 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/eip-5792-middleware`](packages/eip-5792-middleware) - [`@metamask/eip-7702-internal-rpc-middleware`](packages/eip-7702-internal-rpc-middleware) - [`@metamask/eip1193-permission-middleware`](packages/eip1193-permission-middleware) +- [`@metamask/ens-controller`](packages/ens-controller) - [`@metamask/eth-block-tracker`](packages/eth-block-tracker) - [`@metamask/eth-json-rpc-middleware`](packages/eth-json-rpc-middleware) - [`@metamask/eth-json-rpc-provider`](packages/eth-json-rpc-provider) @@ -180,6 +181,7 @@ linkStyle default opacity:0.5 eip_5792_middleware(["@metamask/eip-5792-middleware"]); eip_7702_internal_rpc_middleware(["@metamask/eip-7702-internal-rpc-middleware"]); eip1193_permission_middleware(["@metamask/eip1193-permission-middleware"]); + ens_controller(["@metamask/ens-controller"]); eth_block_tracker(["@metamask/eth-block-tracker"]); eth_json_rpc_middleware(["@metamask/eth-json-rpc-middleware"]); eth_json_rpc_provider(["@metamask/eth-json-rpc-provider"]); @@ -367,6 +369,7 @@ linkStyle default opacity:0.5 config_registry_controller --> keyring_controller; config_registry_controller --> messenger; config_registry_controller --> polling_controller; + config_registry_controller --> profile_sync_controller; config_registry_controller --> remote_feature_flag_controller; connectivity_controller --> base_controller; connectivity_controller --> messenger; @@ -394,6 +397,10 @@ linkStyle default opacity:0.5 eip1193_permission_middleware --> controller_utils; eip1193_permission_middleware --> json_rpc_engine; eip1193_permission_middleware --> permission_controller; + ens_controller --> base_controller; + ens_controller --> controller_utils; + ens_controller --> messenger; + ens_controller --> network_controller; eth_block_tracker --> eth_json_rpc_provider; eth_block_tracker --> json_rpc_engine; eth_json_rpc_middleware --> eth_block_tracker; @@ -536,6 +543,7 @@ linkStyle default opacity:0.5 phishing_controller --> messenger; phishing_controller --> transaction_controller; polling_controller --> base_controller; + polling_controller --> network_controller; polling_controller --> messenger; preferences_controller --> base_controller; preferences_controller --> messenger; @@ -641,6 +649,7 @@ linkStyle default opacity:0.5 transaction_pay_controller --> gas_fee_controller; transaction_pay_controller --> keyring_controller; transaction_pay_controller --> messenger; + transaction_pay_controller --> money_account_utils; transaction_pay_controller --> network_controller; transaction_pay_controller --> ramps_controller; transaction_pay_controller --> remote_feature_flag_controller; @@ -661,7 +670,6 @@ linkStyle default opacity:0.5 wallet --> approval_controller; wallet --> base_controller; wallet --> claims_controller; - wallet --> config_registry_controller; wallet --> connectivity_controller; wallet --> controller_utils; wallet --> gas_fee_controller; @@ -677,7 +685,6 @@ linkStyle default opacity:0.5 wallet --> transaction_controller; wallet_cli --> analytics_controller; wallet_cli --> base_controller; - wallet_cli --> config_registry_controller; wallet_cli --> messenger; wallet_cli --> remote_feature_flag_controller; wallet_cli --> storage_service; diff --git a/codeowners.ts b/codeowners.ts index 77aaddba114..5c4218cba36 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -148,6 +148,9 @@ const PACKAGES: Record = { 'eip1193-permission-middleware': { teams: ['@MetaMask/core-platform'], }, + 'ens-controller': { + teams: ['@MetaMask/confirmations'], + }, 'eth-block-tracker': { teams: ['@MetaMask/core-platform'], }, @@ -447,6 +450,7 @@ function buildTeamSections(): CodeownersSection[] { rules: [ buildRuleForPackage('address-book-controller'), buildRuleForPackage('approval-controller'), + buildRuleForPackage('ens-controller'), buildRuleForPackage('gas-fee-controller'), buildRuleForPackage('logging-controller'), buildRuleForPackage('message-manager'), @@ -667,6 +671,7 @@ function buildPackageReleaseSection(): CodeownersSection { 'earn-controller', 'money-account-balance-service', 'money-account-api-data-service', + 'ens-controller', 'gas-fee-controller', 'gator-permissions-controller', 'geolocation-controller', diff --git a/eslint-suppressions.json b/eslint-suppressions.json index cc77f42c19a..d63a487fe33 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -958,6 +958,22 @@ "count": 1 } }, + "packages/ens-controller/src/EnsController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 11 + }, + "@typescript-eslint/prefer-nullish-coalescing": { + "count": 1 + }, + "no-param-reassign": { + "count": 1 + } + }, + "packages/ens-controller/src/EnsController.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 6 + } + }, "packages/eth-block-tracker/tests/withBlockTracker.ts": { "no-restricted-syntax": { "count": 3 @@ -1503,7 +1519,12 @@ }, "packages/perps-controller/src/PerpsController.ts": { "no-restricted-syntax": { - "count": 2 + "count": 3 + } + }, + "packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts": { + "@typescript-eslint/no-unused-vars": { + "count": 1 } }, "packages/perps-controller/src/utils/myxAdapter.ts": { @@ -1600,6 +1621,14 @@ "count": 1 } }, + "packages/polling-controller/src/BlockTrackerPollingController.test.ts": { + "@typescript-eslint/explicit-function-return-type": { + "count": 2 + }, + "no-restricted-syntax": { + "count": 1 + } + }, "packages/profile-metrics-controller/src/index.ts": { "no-restricted-syntax": { "count": 2 @@ -2189,11 +2218,6 @@ "count": 6 } }, - "packages/user-operation-controller/src/BlockTrackerPollingController.test.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 1 - } - }, "packages/user-operation-controller/src/UserOperationController.test.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 6 diff --git a/eslint.config.mjs b/eslint.config.mjs index 297616d0ddb..4275fa3d871 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -358,6 +358,7 @@ const config = createConfig([ 'packages/assets-controllers/src/TokenRatesController.ts', 'packages/assets-controllers/src/TokensController.ts', 'packages/controller-utils/src/siwe.ts', + 'packages/ens-controller/src/EnsController.ts', 'packages/gas-fee-controller/src/GasFeeController.ts', 'packages/logging-controller/src/LoggingController.ts', 'packages/message-manager/src/AbstractMessageManager.ts', diff --git a/knip.config.mts b/knip.config.mts index d3d13d37c46..19e0a707ecc 100644 --- a/knip.config.mts +++ b/knip.config.mts @@ -95,6 +95,9 @@ const config: KnipConfig = { 'packages/eip1193-permission-middleware': { ignoreDependencies: ['@metamask/rpc-errors'], }, + 'packages/ens-controller': { + ignoreDependencies: ['punycode'], + }, 'packages/foundryup': { // `anvil` and `sysctl` are external system binaries, not npm packages. ignoreBinaries: ['anvil', 'sysctl'], diff --git a/package.json b/package.json index 29cf8c600ed..e69e1ae3a25 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "1202.0.0", + "version": "1191.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index a182b8e1e7c..f78a2f04c59 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,27 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [8.0.0] - -### Added - -- **BREAKING:** Add `:{import,export}State` ([#9663](https://github.com/MetaMask/core/pull/9663), [#9826](https://github.com/MetaMask/core/pull/9826), [#9863](https://github.com/MetaMask/core/pull/9863), [#9864](https://github.com/MetaMask/core/pull/9864), [#9883](https://github.com/MetaMask/core/pull/9883)) - - The following actions must be registered on the controller's messenger: `MultichainAccountService:createMultichainAccountWallet`, `KeyringController:with{Controller,KeyringV2,KeyringV2Unsafe}`. - - This can be used to serialize/deserialize the entire account-tree state (metadata + secrets if needed). - - The `password` is required whenever secrets are requested. - - The payload is versionned and hard-coded to version 1 for now. -- Add `AccountTreeController:initialized` event, emitted at the end of `init()` when the account tree is fully built and ready to consume ([#9880](https://github.com/MetaMask/core/pull/9880)) -- Add `AccountTreeController:uninitialized` event, emitted at the end of `clearState()` when the account tree has been torn down ([#9880](https://github.com/MetaMask/core/pull/9880)) - ### Changed - Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.0` ([#9807](https://github.com/MetaMask/core/pull/9807)) -- Bump `@metamask/multichain-account-service` from `^13.0.1` to `^13.0.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) - -### Fixed - -- `clearState` now resets internal mappings and resets selected account group through `:selectedAccountGroupChange` ([#9825](https://github.com/MetaMask/core/pull/9825)) - - Consumers are (and were already) expected to handle `''` for `:selectedAccountGroupChange` (which can happens during onboarding, and now, during wallet resets). ## [7.6.1] @@ -670,8 +652,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5847](https://github.com/MetaMask/core/pull/5847)) - Grouping accounts into 3 main categories: Entropy source, Snap ID, keyring types. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@8.0.0...HEAD -[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.6.1...@metamask/account-tree-controller@8.0.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.6.1...HEAD [7.6.1]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.6.0...@metamask/account-tree-controller@7.6.1 [7.6.0]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.5.5...@metamask/account-tree-controller@7.6.0 [7.5.5]: https://github.com/MetaMask/core/compare/@metamask/account-tree-controller@7.5.4...@metamask/account-tree-controller@7.5.5 diff --git a/packages/account-tree-controller/package.json b/packages/account-tree-controller/package.json index 33048bf3a19..91581fb8ac6 100644 --- a/packages/account-tree-controller/package.json +++ b/packages/account-tree-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/account-tree-controller", - "version": "8.0.0", + "version": "7.6.1", "description": "Controller to group account together based on some pre-defined rules", "keywords": [ "Ethereum", @@ -60,7 +60,7 @@ "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", - "@metamask/multichain-account-service": "^13.0.2", + "@metamask/multichain-account-service": "^13.0.1", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/snaps-controllers": "^19.0.0", "@metamask/snaps-sdk": "^11.0.0", @@ -73,7 +73,6 @@ "devDependencies": { "@metamask/account-api": "^2.0.0", "@metamask/auto-changelog": "^6.1.0", - "@metamask/eth-hd-keyring": "^15.0.0", "@metamask/providers": "^22.1.0", "@ts-bridge/cli": "^0.6.4", "@types/jest": "^30.0.0", diff --git a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts index 5b1d2d2a51b..be1ee052f3f 100644 --- a/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts +++ b/packages/account-tree-controller/src/AccountTreeController-method-action-types.ts @@ -233,44 +233,6 @@ export type AccountTreeControllerSyncWithUserStorageAtLeastOnceAction = { handler: AccountTreeController['syncWithUserStorageAtLeastOnce']; }; -/** - * Produces a versioned snapshot of the current wallet and group state. - * - * When `options.includeSecrets` is `true`, `options.password` is required - * and verified against the vault before any secret is read. Without - * `includeSecrets`, only metadata (names, pinned, hidden) is exported and - * no password is needed. - * - * @param options - Export options. - * @returns A promise resolving to an `AccountTreeSnapshot`. - * @throws If the vault is locked or the password is incorrect. - */ -export type AccountTreeControllerExportStateAction = { - type: `AccountTreeController:exportState`; - handler: AccountTreeController['exportState']; -}; - -/** - * Applies a validated snapshot to the current state. - * - * Accepts an {@link AccountTreeSnapshot} only — untrusted wire data must be - * parsed with {@link AccountTreeSnapshot.deserialize} first. Callers may - * filter the snapshot with {@link AccountTreeSnapshot.filterWallets}, - * {@link AccountTreeSnapshot.filterGroups}, or - * {@link AccountTreeSnapshot.filterAllGroups} before importing. - * - * New mnemonic wallets are imported via `MultichainAccountService` and new - * private-key accounts via `KeyringController`. Metadata (name, pinned, - * hidden) is applied to all existing and newly created wallets / groups. - * - * @param snapshot - The validated snapshot to import. - * @returns A promise that resolves when the import is complete. - */ -export type AccountTreeControllerImportStateAction = { - type: `AccountTreeController:importState`; - handler: AccountTreeController['importState']; -}; - /** * Union of all AccountTreeController action types. */ @@ -292,6 +254,4 @@ export type AccountTreeControllerMethodActions = | AccountTreeControllerSetAccountGroupHiddenAction | AccountTreeControllerClearStateAction | AccountTreeControllerSyncWithUserStorageAction - | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction - | AccountTreeControllerExportStateAction - | AccountTreeControllerImportStateAction; + | AccountTreeControllerSyncWithUserStorageAtLeastOnceAction; diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index bd1bd37b85c..8ec81ea7d95 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -44,7 +44,6 @@ import type { BackupAndSyncAnalyticsEventPayload } from './backup-and-sync/analy import { BackupAndSyncService } from './backup-and-sync/service/index.js'; import { isAccountGroupNameUnique } from './group.js'; import { getAccountWalletNameFromKeyringType } from './rules/keyring.js'; -import { makeLocalMnemonicWallet } from './state/tests/helpers.js'; import type { AccountTreeControllerState } from './types.js'; // Local mock of EMPTY_ACCOUNT to avoid circular dependency @@ -247,13 +246,33 @@ const MOCK_PREPOPULATED_GROUP_ID = toMultichainAccountGroupId( const MOCK_PREPOPULATED_STATE: Partial = { selectedAccountGroup: MOCK_PREPOPULATED_GROUP_ID, accountTree: { - wallets: makeLocalMnemonicWallet(MOCK_HD_KEYRING_1.metadata.id, [ - { - groupIndex: MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, - name: 'Account 1', - accounts: [MOCK_HD_ACCOUNT_1.id], + wallets: { + [MOCK_PREPOPULATED_WALLET_ID]: { + id: MOCK_PREPOPULATED_WALLET_ID, + type: AccountWalletType.Entropy, + status: 'ready', + groups: { + [MOCK_PREPOPULATED_GROUP_ID]: { + id: MOCK_PREPOPULATED_GROUP_ID, + type: AccountGroupType.MultichainAccount, + accounts: [MOCK_HD_ACCOUNT_1.id], + metadata: { + name: 'Account 1', + entropy: { + groupIndex: MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + }, + pinned: false, + hidden: false, + lastSelected: 0, + }, + }, + }, + metadata: { + name: 'Wallet 1', + entropy: { id: MOCK_HD_KEYRING_1.metadata.id }, + }, }, - ]), + }, }, }; @@ -322,8 +341,6 @@ function setup({ KeyringController: { keyrings: KeyringObject[]; getState: jest.Mock; - verifyPassword: jest.Mock; - withController: jest.Mock; }; AccountsController: { accounts: InternalAccount[]; @@ -347,8 +364,6 @@ function setup({ KeyringController: { keyrings, getState: jest.fn(), - verifyPassword: jest.fn().mockResolvedValue(undefined), - withController: jest.fn(), }, AccountsController: { accounts, @@ -450,26 +465,6 @@ function setup({ 'KeyringController:getState', mocks.KeyringController.getState, ); - - messenger.registerActionHandler( - 'KeyringController:verifyPassword', - mocks.KeyringController.verifyPassword, - ); - - // Default: call the callback with no existing keyrings so private-key - // imports are a no-op unless the test overrides this handler. - mocks.KeyringController.withController.mockImplementation( - async ( - callback: (ctx: { - keyrings: { keyring: { type: string }; keyringV2: unknown }[]; - addNewKeyring: jest.Mock; - }) => Promise, - ) => callback({ keyrings: [], addNewKeyring: jest.fn() }), - ); - messenger.registerActionHandler( - 'KeyringController:withController', - mocks.KeyringController.withController, - ); } const accountTreeControllerMessenger = @@ -4713,53 +4708,6 @@ describe('AccountTreeController', () => { ); expect(spy).toHaveBeenCalledWith(groupId, hidden); }); - - it('calls exportState via AccountTreeController:exportState', async () => { - const spy = jest.spyOn(AccountTreeController.prototype, 'exportState'); - - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - messenger.registerActionHandler( - 'KeyringController:withKeyringV2Unsafe', - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id, - mnemonic: null, - }, - }), - ); - - await messenger.call('AccountTreeController:exportState'); - expect(spy).toHaveBeenCalled(); - }); - - it('calls importState via AccountTreeController:importState', async () => { - const spy = jest - .spyOn(AccountTreeController.prototype, 'importState') - .mockResolvedValue(); - - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const mockSnapshot = {} as Parameters< - AccountTreeController['importState'] - >[0]; - await messenger.call('AccountTreeController:importState', mockSnapshot); - expect(spy).toHaveBeenCalledWith(mockSnapshot); - }); }); describe('Event Emissions', () => { @@ -5412,100 +5360,6 @@ describe('AccountTreeController', () => { expect(updatedListener).toHaveBeenCalledWith(expectedGroup); expect(expectedGroup.metadata.hidden).toBe(true); }); - - it('emits initialized after init() completes', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - const initializedListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:initialized', - initializedListener, - ); - - controller.init(); - - expect(initializedListener).toHaveBeenCalledTimes(1); - expect(initializedListener).toHaveBeenCalledWith(controller.state); - }); - - it('emits initialized again after reinit()', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - const initializedListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:initialized', - initializedListener, - ); - - controller.init(); - jest.clearAllMocks(); - - controller.reinit(); - - expect(initializedListener).toHaveBeenCalledTimes(1); - expect(initializedListener).toHaveBeenCalledWith(controller.state); - }); - - it('does NOT emit initialized during clearState()', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const initializedListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:initialized', - initializedListener, - ); - - controller.clearState(); - - expect(initializedListener).not.toHaveBeenCalled(); - }); - - it('emits uninitialized after clearState()', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const uninitializedListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:uninitialized', - uninitializedListener, - ); - - controller.clearState(); - - expect(uninitializedListener).toHaveBeenCalledTimes(1); - }); - - it('does NOT emit uninitialized during init()', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - const uninitializedListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:uninitialized', - uninitializedListener, - ); - - controller.init(); - - expect(uninitializedListener).not.toHaveBeenCalled(); - }); }); describe('syncWithUserStorage', () => { @@ -5661,49 +5515,6 @@ describe('AccountTreeController', () => { getDefaultAccountTreeControllerState(), ); }); - - it('clears in-memory reverse-lookup Maps', () => { - const { controller } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - expect(controller.getAccountContext(MOCK_HD_ACCOUNT_1.id)).toBeDefined(); - expect( - controller.getAccountGroupObject('entropy:mock-keyring-id-1/0'), - ).toBeDefined(); - - controller.clearState(); - - expect( - controller.getAccountContext(MOCK_HD_ACCOUNT_1.id), - ).toBeUndefined(); - expect( - controller.getAccountGroupObject('entropy:mock-keyring-id-1/0'), - ).toBeUndefined(); - }); - - it('publishes selectedAccountGroupChange event with empty group ID', () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const previousGroupId = controller.state.selectedAccountGroup; - const mockListener = jest.fn(); - messenger.subscribe( - 'AccountTreeController:selectedAccountGroupChange', - mockListener, - ); - - controller.clearState(); - - expect(mockListener).toHaveBeenCalledWith('', previousGroupId); - }); }); describe('backup and sync config initialization', () => { @@ -6578,175 +6389,4 @@ describe('AccountTreeController', () => { }); }); }); - - describe('exportState / importState round-trip', () => { - it('preserves wallet and group metadata across a metadata-only export/import cycle', async () => { - const { controller, messenger } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - const walletId = toMultichainAccountWalletId( - MOCK_HD_KEYRING_1.metadata.id, - ); - const groupId = toMultichainAccountGroupId( - walletId, - MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, - ); - - // Set custom metadata before export. - controller.setAccountWalletName(walletId, 'My Custom Wallet'); - controller.setAccountGroupName(groupId, 'My Custom Account'); - controller.setAccountGroupPinned(groupId, true); - controller.setAccountGroupHidden(groupId, false); - - // Register handlers that export needs but the default setup() doesn't provide. - // withKeyringV2Unsafe: returns the entropy source ID derived from the keyring. - messenger.registerActionHandler( - 'KeyringController:withKeyringV2Unsafe', - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id, - mnemonic: null, - }, - }), - ); - - // --- EXPORT --- - const snapshot = await controller.exportState(); - const payload = snapshot.serialize(); - - expect(payload.wallets).toHaveLength(1); - const exportedWallet = payload.wallets[0]; - expect(exportedWallet.type).toBe('mnemonic'); - expect(exportedWallet.metadata.name).toBe('My Custom Wallet'); - expect(exportedWallet.groups[0]?.metadata.name).toBe('My Custom Account'); - expect(exportedWallet.groups[0]?.metadata.pinned).toBe(true); - expect(exportedWallet.groups[0]?.metadata.hidden).toBe(false); - - // The snapshot's idMap bridges local IDs ↔ payload IDs. - expect(snapshot.toPayloadId(walletId)).toBe( - `wallet:${MOCK_HD_KEYRING_1.metadata.id}`, - ); - expect( - snapshot.toLocalId(`wallet:${MOCK_HD_KEYRING_1.metadata.id}`), - ).toBe(walletId); - - // Mutate metadata so the import can restore it. - controller.setAccountWalletName(walletId, 'Overwritten Wallet Name'); - controller.setAccountGroupName(groupId, 'Overwritten Account Name'); - controller.setAccountGroupPinned(groupId, false); - controller.setAccountGroupHidden(groupId, true); - - expect( - controller.state.accountTree.wallets[walletId]?.metadata.name, - ).toBe('Overwritten Wallet Name'); - - // --- IMPORT --- - // withKeyringV2Unsafe is called again during import to find the matching wallet. - // It's already registered; the existing handler stays in place. - await controller.importState(snapshot); - - // After import, original metadata should be restored. - expect( - controller.state.accountTree.wallets[walletId]?.metadata.name, - ).toBe('My Custom Wallet'); - expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.metadata.name, - ).toBe('My Custom Account'); - expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.metadata.pinned, - ).toBe(true); - expect( - controller.state.accountTree.wallets[walletId]?.groups[groupId] - ?.metadata.hidden, - ).toBe(false); - }); - - it('throws when exporting with a locked vault regardless of includeSecrets', async () => { - const { controller, mocks } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - mocks.KeyringController.getState.mockReturnValue({ - isUnlocked: false, - keyrings: mocks.KeyringController.keyrings, - }); - - await expect( - controller.exportState({ includeSecrets: false }), - ).rejects.toThrow('Cannot export account tree when vault is locked'); - - await expect( - controller.exportState({ - includeSecrets: true, - password: 'test-password', - }), - ).rejects.toThrow('Cannot export account tree when vault is locked'); - }); - - it('throws when exporting with a wrong password', async () => { - const { controller, mocks } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - mocks.KeyringController.verifyPassword.mockRejectedValue( - new Error('Invalid password'), - ); - - await expect( - controller.exportState({ - includeSecrets: true, - password: 'wrong-password', - }), - ).rejects.toThrow('Invalid password'); - }); - - it('verifies password before exporting', async () => { - const { controller, messenger, mocks } = setup({ - accounts: [MOCK_HD_ACCOUNT_1], - keyrings: [MOCK_HD_KEYRING_1], - }); - - controller.init(); - - messenger.registerActionHandler( - 'KeyringController:withKeyringV2Unsafe', - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - toEntropySourceId: async () => MOCK_HD_KEYRING_1.metadata.id, - // Must be even-length for encodeMnemonic (uses Uint16Array internally). - mnemonic: new Uint8Array([1, 2, 3, 4]), - }, - }), - ); - - await controller.exportState({ - includeSecrets: true, - password: 'correct-password', - }); - - expect(mocks.KeyringController.verifyPassword).toHaveBeenCalledWith( - 'correct-password', - ); - }); - }); }); diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index b7c03ca4893..f2460bb3fc5 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -36,10 +36,6 @@ import type { Rule } from './rule.js'; import { EntropyRule } from './rules/entropy.js'; import { KeyringRule } from './rules/keyring.js'; import { SnapRule } from './rules/snap.js'; -import { exportState } from './state/export.js'; -import { importState } from './state/import.js'; -import type { ExportStateOptions } from './state/payload.js'; -import type { AccountTreeSnapshot } from './state/snapshot.js'; import type { AccountTreeControllerConfig, AccountTreeControllerInternalBackupAndSyncConfig, @@ -69,8 +65,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'syncWithUserStorageAtLeastOnce', 'init', 'reinit', - 'exportState', - 'importState', ] as const; const accountTreeControllerMetadata: StateMetadata = @@ -440,9 +434,8 @@ export class AccountTreeController extends BaseController< previousSelectedAccountGroup, ); - this.#initialized = true; log('Initialized!'); - this.messenger.publish(`${controllerName}:initialized`, this.state); + this.#initialized = true; } /** @@ -1820,8 +1813,6 @@ export class AccountTreeController extends BaseController< clearState(): void { log('Clearing state'); - const previousSelectedAccountGroup = this.state.selectedAccountGroup; - this.update(() => { return { ...getDefaultAccountTreeControllerState(), @@ -1829,22 +1820,8 @@ export class AccountTreeController extends BaseController< }); this.#backupAndSyncService.clearState(); - // Clear in-memory reverse-lookup Maps so stale data is not accessible - // between this call and the next init(). - this.#accountIdToContext.clear(); - this.#groupIdToWalletId.clear(); - - // Notify subscribers that the selected group has been cleared, - // mirroring what #setSelectedAccountGroup does on normal transitions. - this.messenger.publish( - `${controllerName}:selectedAccountGroupChange`, - '', - previousSelectedAccountGroup, - ); - // So we know we have to call `init` again. this.#initialized = false; - this.messenger.publish(`${controllerName}:uninitialized`); } /** @@ -1878,60 +1855,6 @@ export class AccountTreeController extends BaseController< return this.#backupAndSyncService.performFullSyncAtLeastOnce(); } - /** - * Produces a versioned snapshot of the current wallet and group state. - * - * When `options.includeSecrets` is `true`, `options.password` is required - * and verified against the vault before any secret is read. Without - * `includeSecrets`, only metadata (names, pinned, hidden) is exported and - * no password is needed. - * - * @param options - Export options. - * @returns A promise resolving to an `AccountTreeSnapshot`. - * @throws If the vault is locked or the password is incorrect. - */ - async exportState( - options?: ExportStateOptions, - ): Promise { - return exportState( - { getState: () => this.state, messenger: this.messenger }, - options, - ); - } - - /** - * Applies a validated snapshot to the current state. - * - * Accepts an {@link AccountTreeSnapshot} only — untrusted wire data must be - * parsed with {@link AccountTreeSnapshot.deserialize} first. Callers may - * filter the snapshot with {@link AccountTreeSnapshot.filterWallets}, - * {@link AccountTreeSnapshot.filterGroups}, or - * {@link AccountTreeSnapshot.filterAllGroups} before importing. - * - * New mnemonic wallets are imported via `MultichainAccountService` and new - * private-key accounts via `KeyringController`. Metadata (name, pinned, - * hidden) is applied to all existing and newly created wallets / groups. - * - * @param snapshot - The validated snapshot to import. - * @returns A promise that resolves when the import is complete. - */ - async importState(snapshot: AccountTreeSnapshot): Promise { - return importState( - { - getState: () => this.state, - messenger: this.messenger, - setWalletName: (id, name) => this.setAccountWalletName(id, name), - setAccountGroupName: (id, name) => - this.setAccountGroupName(id, name, true), - setAccountGroupPinned: (id, pinned) => - this.setAccountGroupPinned(id, pinned), - setAccountGroupHidden: (id, hidden) => - this.setAccountGroupHidden(id, hidden), - }, - snapshot, - ); - } - /** * Creates an backup and sync context for sync operations. * Used by the backup and sync service. diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index 660c74db74b..bbc654628f4 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -17,8 +17,6 @@ export type { AccountTreeControllerAccountGroupCreatedEvent, AccountTreeControllerAccountGroupUpdatedEvent, AccountTreeControllerAccountGroupRemovedEvent, - AccountTreeControllerInitializedEvent, - AccountTreeControllerUninitializedEvent, AccountTreeControllerEvents, AccountTreeControllerMessenger, } from './types.js'; @@ -42,8 +40,6 @@ export type { AccountTreeControllerSyncWithUserStorageAtLeastOnceAction, AccountTreeControllerInitAction, AccountTreeControllerReinitAction, - AccountTreeControllerExportStateAction, - AccountTreeControllerImportStateAction, } from './AccountTreeController-method-action-types.js'; export type { AccountContext } from './AccountTreeController.js'; @@ -52,27 +48,3 @@ export { AccountTreeController, getDefaultAccountTreeControllerState, } from './AccountTreeController.js'; - -export type { - AccountTreePayload, - AccountTreePayloadStructType, - AccountWalletMnemonicPayload, - AccountWalletPrivateKeyPayload, - AccountWalletMnemonicGroupEntry, - AccountWalletPrivateKeyGroupEntry, - AccountWalletPayloadId, - AccountGroupPayloadId, - AccountTreeSnapshotWallet, - AccountTreeSnapshotGroup, - ExportStateOptions, -} from './state/payload.js'; - -export { - AccountWalletPayloadType, - AccountWalletPrivateKeyEncoding, - AccountTreePayloadStruct, - assertAccountTreePayload, -} from './state/payload.js'; - -export { AccountTreeSnapshot } from './state/snapshot.js'; -export { IdMap } from './state/id-map.js'; diff --git a/packages/account-tree-controller/src/state/export.test.ts b/packages/account-tree-controller/src/state/export.test.ts deleted file mode 100644 index 3ca6d8ce8fa..00000000000 --- a/packages/account-tree-controller/src/state/export.test.ts +++ /dev/null @@ -1,672 +0,0 @@ -import { - AccountWalletType, - toAccountGroupId, - toMultichainAccountGroupId, - toAccountWalletId, -} from '@metamask/account-api'; -import { AccountGroupType } from '@metamask/account-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; -import { SnapId } from '@metamask/snaps-sdk'; -import { InternalAccount } from '@metamask/snaps-utils'; - -import type { - AccountTreeControllerMessenger, - AccountTreeControllerState, -} from '../types.js'; -import type { ExportContext } from './export.js'; -import { - exportState, - isMnemonicWalletObject, - isPrivateKeyWalletObject, -} from './export.js'; -import { - AccountWalletMnemonicGroupEntry, - AccountWalletPayloadType, - AccountWalletPrivateKeyEncoding, - toGroupPayloadId, - toWalletPayloadId, -} from './payload.js'; -import { - makeLocalKeyringWallet, - makeLocalMnemonicWallet, -} from './tests/helpers.js'; - -const MOCK_PRIVATE_KEY_PAYLOAD_ID = toWalletPayloadId( - AccountWalletPayloadType.PrivateKey, -); - -const MOCK_HD_WALLET_ID = toAccountWalletId( - AccountWalletType.Entropy, - 'mock-entropy-id', -); -const MOCK_HD_GROUP_ID = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 0); -const MOCK_PRIVATE_KEY_WALLET_ID = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, -); -const MOCK_PRIVATE_KEY_GROUP_ID = toAccountGroupId( - MOCK_PRIVATE_KEY_WALLET_ID, - '0xabc', -); - -const MOCK_HD_WALLET_STATE = makeLocalMnemonicWallet('mock-entropy-id', [ - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, -]); - -const MOCK_PRIVATE_KEY_WALLET_STATE = makeLocalKeyringWallet( - KeyringTypes.simple, - [ - { - address: '0xabc', - name: 'Imported 1', - accounts: ['account-private-key-1'], - }, - ], -); - -/** - * Creates an ExportContext with individual jest mocks per action so tests can - * configure them with `.mockReturnValue` / `.mockImplementation`. - * - * @param options - Setup options. - * @param options.wallets - Initial wallet state. - * @param options.isUnlocked - Whether the vault reports as unlocked (default: true). - * @returns context, mocks (per-action jest.fn()s), and the raw messenger mock. - */ -function setup({ - wallets = {} as AccountTreeControllerState['accountTree']['wallets'], - isUnlocked = true, -}: { - wallets?: AccountTreeControllerState['accountTree']['wallets']; - isUnlocked?: boolean; -} = {}): { - context: ExportContext; - /* eslint-disable @typescript-eslint/naming-convention */ - mocks: { - KeyringController: { - getState: jest.Mock; - verifyPassword: jest.Mock; - withKeyringV2Unsafe: jest.Mock; - withKeyringV2: jest.Mock; - }; - AccountsController: { getAccount: jest.Mock }; - }; - /* eslint-enable @typescript-eslint/naming-convention */ - messenger: AccountTreeControllerMessenger; -} { - const mocks = { - KeyringController: { - getState: jest.fn().mockReturnValue({ isUnlocked, keyrings: [] }), - verifyPassword: jest.fn().mockResolvedValue(undefined), - withKeyringV2Unsafe: jest.fn(), - withKeyringV2: jest.fn(), - }, - AccountsController: { - getAccount: jest.fn(), - }, - }; - - const messenger = { - call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { - switch (action) { - case 'KeyringController:getState': - return mocks.KeyringController.getState(); - case 'KeyringController:verifyPassword': - return mocks.KeyringController.verifyPassword(...args); - case 'KeyringController:withKeyringV2Unsafe': - return mocks.KeyringController.withKeyringV2Unsafe(...args); - case 'KeyringController:withKeyringV2': - return mocks.KeyringController.withKeyringV2(...args); - case 'AccountsController:getAccount': - return mocks.AccountsController.getAccount(...args); - default: - return undefined; - } - }), - } as unknown as AccountTreeControllerMessenger; - - const state: AccountTreeControllerState = { - accountTree: { wallets }, - selectedAccountGroup: '', - isAccountTreeSyncingInProgress: false, - hasAccountTreeSyncingSyncedAtLeastOnce: false, - accountGroupsMetadata: {}, - accountWalletsMetadata: {}, - }; - - const context: ExportContext = { - getState: () => state, - messenger, - }; - - return { context, mocks, messenger }; -} - -function makeHdKeyringHandler( - entropySourceId: string, - mnemonic: Uint8Array | null = null, -): jest.Mock { - return jest - .fn() - .mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - toEntropySourceId: async () => entropySourceId, - mnemonic, - }, - }), - ); -} - -function makePrivateKeyExportHandler( - result: { privateKey: string; encoding: string } | undefined, -): jest.Mock { - return jest - .fn() - .mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - exportAccount: async () => result, - }, - }), - ); -} - -describe('isMnemonicWalletObject', () => { - it('returns true for an entropy wallet', () => { - expect( - isMnemonicWalletObject(MOCK_HD_WALLET_STATE[MOCK_HD_WALLET_ID]), - ).toBe(true); - }); - - it('returns false for a keyring wallet', () => { - expect( - isMnemonicWalletObject( - MOCK_PRIVATE_KEY_WALLET_STATE[MOCK_PRIVATE_KEY_WALLET_ID], - ), - ).toBe(false); - }); -}); - -describe('isPrivateKeyWalletObject', () => { - it('returns true for a simple-keyring wallet', () => { - expect( - isPrivateKeyWalletObject( - MOCK_PRIVATE_KEY_WALLET_STATE[MOCK_PRIVATE_KEY_WALLET_ID], - ), - ).toBe(true); - }); - - it('returns false for an entropy wallet', () => { - expect( - isPrivateKeyWalletObject(MOCK_HD_WALLET_STATE[MOCK_HD_WALLET_ID]), - ).toBe(false); - }); - - it('returns false for a non-simple keyring wallet (e.g. ledger)', () => { - const ledgerWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.ledger, - ); - const ledgerGroupId = toAccountGroupId(ledgerWalletId, '0xhw'); - const ledgerWallet: AccountTreeControllerState['accountTree']['wallets'][string] = - { - id: ledgerWalletId, - type: AccountWalletType.Keyring, - status: 'ready', - groups: { - [ledgerGroupId]: { - id: ledgerGroupId, - type: AccountGroupType.SingleAccount, - accounts: ['account-hw-1'], - metadata: { - name: 'Ledger 1', - pinned: false, - hidden: false, - lastSelected: 0, - }, - }, - }, - metadata: { name: 'Ledger', keyring: { type: KeyringTypes.ledger } }, - }; - expect(isPrivateKeyWalletObject(ledgerWallet)).toBe(false); - }); -}); - -describe('exportState', () => { - describe('vault locking', () => { - it('throws when the vault is locked', async () => { - const { context } = setup({ isUnlocked: false }); - await expect(exportState(context)).rejects.toThrow( - 'Cannot export account tree when vault is locked', - ); - }); - - it('throws when the vault is locked even without includeSecrets', async () => { - const { context } = setup({ isUnlocked: false }); - await expect( - exportState(context, { includeSecrets: false }), - ).rejects.toThrow('Cannot export account tree when vault is locked'); - }); - }); - - describe('with no wallets', () => { - it('returns an empty snapshot', async () => { - const { context } = setup(); - const snapshot = await exportState(context); - expect(snapshot.serialize().wallets).toHaveLength(0); - }); - }); - - describe('with an HD wallet', () => { - it('exports the wallet without secrets by default', async () => { - const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); - // encodeMnemonic uses Uint16Array internally -- must be even-length. - mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( - 'stable-entropy-id', - new Uint8Array([1, 2, 3, 4]), - ); - - const snapshot = await exportState(context); - const wallet = snapshot.serialize().wallets[0]; - - expect(wallet?.id).toBe(toWalletPayloadId('stable-entropy-id')); - expect(wallet?.type).toBe(AccountWalletPayloadType.Mnemonic); - expect(wallet?.metadata.name).toBe('Wallet 1'); - expect((wallet as { value?: string }).value).toBeUndefined(); - }); - - it('exports the wallet with the mnemonic when includeSecrets is true', async () => { - const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); - mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( - 'stable-entropy-id', - new Uint8Array([1, 2, 3, 4]), - ); - - const snapshot = await exportState(context, { - includeSecrets: true, - password: 'test-password', - }); - const wallet = snapshot.serialize().wallets[0] as { value?: string }; - - expect(Array.isArray(wallet.value)).toBe(true); - }); - - it('throws when includeSecrets is true but mnemonic is unavailable', async () => { - const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); - // mnemonic: null -> includeMnemonic will be false -> throws after export. - mocks.KeyringController.withKeyringV2Unsafe = makeHdKeyringHandler( - 'stable-entropy-id', - null, - ); - - await expect( - exportState(context, { - includeSecrets: true, - password: 'test-password', - }), - ).rejects.toThrow('Failed to export mnemonic'); - }); - - it('populates the idMap with wallet and group local↔payload ID pairs', async () => { - const { context, mocks } = setup({ wallets: MOCK_HD_WALLET_STATE }); - mocks.KeyringController.withKeyringV2Unsafe = - makeHdKeyringHandler('stable-entropy-id'); - - const snapshot = await exportState(context); - - expect(snapshot.toLocalId(toWalletPayloadId('stable-entropy-id'))).toBe( - MOCK_HD_WALLET_ID, - ); - expect( - snapshot.toLocalId( - toGroupPayloadId(toWalletPayloadId('stable-entropy-id'), 0), - ), - ).toBe(MOCK_HD_GROUP_ID); - expect(snapshot.toPayloadId(MOCK_HD_WALLET_ID)).toBe( - toWalletPayloadId('stable-entropy-id'), - ); - expect(snapshot.toPayloadId(MOCK_HD_GROUP_ID)).toBe( - toGroupPayloadId(toWalletPayloadId('stable-entropy-id'), 0), - ); - }); - - it('exports groups sorted by groupIndex regardless of insertion order', async () => { - // Insert groups in reverse order so Object.values() returns them as [2, 1, 0]. - const walletsWithReversedGroups = makeLocalMnemonicWallet( - 'mock-entropy-id', - [ - { groupIndex: 2, name: 'Account 3', accounts: ['account-3'] }, - { groupIndex: 1, name: 'Account 2', accounts: ['account-2'] }, - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, - ], - ); - - const { context, mocks } = setup({ wallets: walletsWithReversedGroups }); - mocks.KeyringController.withKeyringV2Unsafe = - makeHdKeyringHandler('stable-entropy-id'); - - const snapshot = await exportState(context); - const groups = snapshot.serialize().wallets[0] - ?.groups as AccountWalletMnemonicGroupEntry[]; - - expect(groups?.map((group) => group.groupIndex)).toStrictEqual([0, 1, 2]); - }); - - it('throws when mnemonic groups have a gap after sorting', async () => { - const walletsWithGap = makeLocalMnemonicWallet('mock-entropy-id', [ - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, - { groupIndex: 2, name: 'Account 3', accounts: ['account-3'] }, - ]); - - const { context, mocks } = setup({ wallets: walletsWithGap }); - mocks.KeyringController.withKeyringV2Unsafe = - makeHdKeyringHandler('stable-entropy-id'); - - await expect(exportState(context)).rejects.toThrow( - 'Found non-contiguous groups in mnemonic wallet', - ); - }); - - it('skips snap and hardware wallets', async () => { - const snapWalletId = toAccountWalletId( - AccountWalletType.Snap, - 'local:mock-snap', - ); - const ledgerWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.ledger, - ); - const snapGroupId = toAccountGroupId(snapWalletId, '0xsnap'); - const ledgerGroupId = toAccountGroupId(ledgerWalletId, '0xhw'); - - const mixedWallets: AccountTreeControllerState['accountTree']['wallets'] = - { - [snapWalletId]: { - id: snapWalletId, - type: AccountWalletType.Snap, - status: 'ready', - groups: { - [snapGroupId]: { - id: snapGroupId, - type: AccountGroupType.SingleAccount, - accounts: ['snap-account-1'], - metadata: { - name: 'Snap 1', - pinned: false, - hidden: false, - lastSelected: 0, - }, - }, - }, - metadata: { - name: 'Snap Wallet', - snap: { id: 'local:mock-snap' as SnapId }, - }, - }, - [ledgerWalletId]: { - id: ledgerWalletId, - type: AccountWalletType.Keyring, - status: 'ready', - groups: { - [ledgerGroupId]: { - id: ledgerGroupId, - type: AccountGroupType.SingleAccount, - accounts: ['hw-account-1'], - metadata: { - name: 'Ledger 1', - pinned: false, - hidden: false, - lastSelected: 0, - }, - }, - }, - metadata: { - name: 'Ledger', - keyring: { type: KeyringTypes.ledger }, - }, - }, - }; - - const { context } = setup({ wallets: mixedWallets }); - const snapshot = await exportState(context); - expect(snapshot.serialize().wallets).toHaveLength(0); - }); - }); - - describe('with a private-key wallet', () => { - it('exports the wallet without secrets', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue({ - id: 'account-private-key-1', - address: '0xabc', - }); - - const snapshot = await exportState(context); - const payload = snapshot.serialize(); - - expect(payload.wallets).toHaveLength(1); - const wallet = payload.wallets[0]; - expect(wallet?.id).toBe(MOCK_PRIVATE_KEY_PAYLOAD_ID); - expect(wallet?.type).toBe(AccountWalletPayloadType.PrivateKey); - expect(wallet?.groups).toHaveLength(1); - expect(wallet?.groups[0]?.id).toBe( - toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xabc'), - ); - expect((wallet?.groups[0] as { value?: unknown })?.value).toBeUndefined(); - }); - - it('exports the wallet with secrets when includeSecrets is true', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue({ - id: 'account-private-key-1', - address: '0xabc', - }); - mocks.KeyringController.withKeyringV2 = makePrivateKeyExportHandler({ - privateKey: '0xdeadbeef', - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }); - - const snapshot = await exportState(context, { - includeSecrets: true, - password: 'test-password', - }); - const group = snapshot.serialize().wallets[0]?.groups[0] as { - value?: { privateKey: number[]; encoding: string; type: string }; - }; - - expect(group.value?.privateKey).toStrictEqual( - Array.from(new TextEncoder().encode('0xdeadbeef')), - ); - expect(group.value?.encoding).toBe( - AccountWalletPrivateKeyEncoding.Hexadecimal, - ); - expect(group.value?.type).toBe('eip155:eoa'); - }); - - it('throws when includeSecrets is true but keyring does not support exportAccount', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue({ - id: 'account-private-key-1', - address: '0xabc', - }); - mocks.KeyringController.withKeyringV2.mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => callback({ keyring: {} }), // No exportAccount method. - ); - - await expect( - exportState(context, { - includeSecrets: true, - password: 'test-password', - }), - ).rejects.toThrow('does not support exportAccount'); - }); - - it('throws when includeSecrets is true but the exported value is absent', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue({ - id: 'account-private-key-1', - address: '0xabc', - }); - mocks.KeyringController.withKeyringV2 = - makePrivateKeyExportHandler(undefined); - - await expect( - exportState(context, { - includeSecrets: true, - password: 'test-password', - }), - ).rejects.toThrow('Failed to export private key'); - }); - - it('skips groups whose first account cannot be found', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue(undefined); - - const snapshot = await exportState(context); - expect(snapshot.serialize().wallets[0]?.groups).toHaveLength(0); - }); - - it('skips groups with no accounts', async () => { - const emptyGroupWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, - ); - const emptyGroupId = toAccountGroupId(emptyGroupWalletId, '0xempty'); - const wallets: AccountTreeControllerState['accountTree']['wallets'] = { - [emptyGroupWalletId]: { - id: emptyGroupWalletId, - type: AccountWalletType.Keyring, - status: 'ready', - groups: { - [emptyGroupId]: { - id: emptyGroupId, - type: AccountGroupType.SingleAccount, - // @ts-expect-error -- deliberately empty for the test - accounts: [], - metadata: { - name: 'Empty', - pinned: false, - hidden: false, - lastSelected: 0, - }, - }, - }, - metadata: { - name: 'Imported Accounts', - keyring: { type: KeyringTypes.simple }, - }, - }, - }; - - const { context } = setup({ wallets }); - const snapshot = await exportState(context); - expect(snapshot.serialize().wallets[0]?.groups).toHaveLength(0); - }); - - it('populates the idMap with private-key wallet and group pairs', async () => { - const { context, mocks } = setup({ - wallets: MOCK_PRIVATE_KEY_WALLET_STATE, - }); - mocks.AccountsController.getAccount.mockReturnValue({ - id: 'account-private-key-1', - address: '0xabc', - }); - - const snapshot = await exportState(context); - - expect(snapshot.toLocalId(MOCK_PRIVATE_KEY_PAYLOAD_ID)).toBe( - MOCK_PRIVATE_KEY_WALLET_ID, - ); - expect( - snapshot.toLocalId( - toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xabc'), - ), - ).toBe(MOCK_PRIVATE_KEY_GROUP_ID); - }); - - it('merges multiple simple-keyring wallets into one private-key payload entry', async () => { - const secondPrivateKeyWalletId = - 'keyring:simple:legacy' as typeof MOCK_PRIVATE_KEY_WALLET_ID; - const secondPrivateKeyGroupId = toAccountGroupId( - secondPrivateKeyWalletId, - '0xdef', - ); - - const wallets: AccountTreeControllerState['accountTree']['wallets'] = { - ...MOCK_PRIVATE_KEY_WALLET_STATE, - [secondPrivateKeyWalletId]: { - id: secondPrivateKeyWalletId, - type: AccountWalletType.Keyring, - status: 'ready', - groups: { - [secondPrivateKeyGroupId]: { - id: secondPrivateKeyGroupId, - type: AccountGroupType.SingleAccount, - accounts: ['account-private-key-2'], - metadata: { - name: 'Imported 2', - pinned: false, - hidden: false, - lastSelected: 0, - }, - }, - }, - metadata: { - name: 'Imported Accounts 2', - keyring: { type: KeyringTypes.simple }, - }, - }, - }; - - const { context, mocks } = setup({ wallets }); - mocks.AccountsController.getAccount.mockImplementation( - (accountId: InternalAccount['id']) => { - if (accountId === 'account-private-key-1') { - return { id: 'account-private-key-1', address: '0xabc' }; - } - if (accountId === 'account-private-key-2') { - return { id: 'account-private-key-2', address: '0xdef' }; - } - return undefined; - }, - ); - - const snapshot = await exportState(context); - const payload = snapshot.serialize(); - - expect(payload.wallets).toHaveLength(1); - expect(payload.wallets[0]?.type).toBe( - AccountWalletPayloadType.PrivateKey, - ); - expect(payload.wallets[0]?.groups).toHaveLength(2); - expect(payload.wallets[0]?.groups.map((group) => group.id)).toStrictEqual( - [ - toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xabc'), - toGroupPayloadId(MOCK_PRIVATE_KEY_PAYLOAD_ID, '0xdef'), - ], - ); - }); - }); -}); diff --git a/packages/account-tree-controller/src/state/export.ts b/packages/account-tree-controller/src/state/export.ts deleted file mode 100644 index 6914820536f..00000000000 --- a/packages/account-tree-controller/src/state/export.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { AccountWalletType } from '@metamask/account-api'; -import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import { EthAccountType } from '@metamask/keyring-api'; -import { PrivateKeyExportedAccount } from '@metamask/keyring-api/v2'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -import type { - AccountTreeControllerMessenger, - AccountTreeControllerState, -} from '../types.js'; -import type { AccountWalletObject } from '../wallet.js'; -import { - AccountWalletEntropyObject, - AccountWalletKeyringObject, -} from '../wallet.js'; -import { IdMap } from './id-map.js'; -import type { - AccountTreeWalletEntry, - AccountWalletMnemonicGroupEntry, - AccountWalletMnemonicPayload, - AccountWalletPrivateKeyGroupEntry, - AccountWalletPrivateKeyPayload, - ExportStateOptions, -} from './payload.js'; -import { - AccountWalletPayloadType, - AccountWalletPrivateKeyEncoding, - toWalletPayloadId, - toGroupPayloadId, -} from './payload.js'; -import { AccountTreeSnapshot } from './snapshot.js'; -import { encodeBytes } from './utils.js'; - -/** - * Returns `true` if `wallet` is an HD entropy wallet ({@link AccountWalletEntropyObject}). - * - * @param wallet - The wallet object to test. - * @returns Type predicate narrowing to {@link AccountWalletEntropyObject}. - */ -export function isMnemonicWalletObject( - wallet: AccountWalletObject, -): wallet is AccountWalletEntropyObject { - return wallet.type === AccountWalletType.Entropy; -} - -/** - * Returns `true` if `wallet` is an imported private-key wallet - * ({@link AccountWalletKeyringObject} with keyring type {@link KeyringTypes.simple}). - * - * @param wallet - The wallet object to test. - * @returns Type predicate narrowing to {@link AccountWalletKeyringObject}. - */ -export function isPrivateKeyWalletObject( - wallet: AccountWalletObject, -): wallet is AccountWalletKeyringObject { - return ( - wallet.type === AccountWalletType.Keyring && - wallet.metadata.keyring.type === KeyringTypes.simple - ); -} - -/** Context required by {@link exportState}. */ -export type ExportContext = { - getState: () => AccountTreeControllerState; - messenger: AccountTreeControllerMessenger; -}; - -/** - * Exports a single entropy (HD) wallet object as an {@link AccountWalletMnemonicPayload}. - * - * Calls `KeyringController:withKeyringV2Unsafe` to derive the stable entropy source ID - * via {@link HdKeyring.toEntropySourceId} and, when `includeSecrets` is `true`, to read - * the raw mnemonic bytes. - * - * @param context - Export context. - * @param walletObj - The local entropy wallet to export. - * @param includeSecrets - When `true`, the BIP-39 mnemonic is included in the payload. - * @param idMap - ID map to populate with local↔payload ID pairs for this wallet and its groups. - * @returns The mnemonic wallet payload entry. - * @throws If `includeSecrets` is `true` but the mnemonic cannot be read from the keyring. - */ -async function exportMnemonicWalletObject( - context: ExportContext, - walletObj: AccountWalletEntropyObject, - includeSecrets: boolean, - idMap: IdMap, -): Promise { - const result = await context.messenger.call( - 'KeyringController:withKeyringV2Unsafe', - // The local wallet entropy ID is the keyring ID. - { id: walletObj.metadata.entropy.id }, - async ({ keyring }) => { - const hdKeyring = keyring as HdKeyring; - const includeMnemonic = - includeSecrets && - hdKeyring.mnemonic !== null && - hdKeyring.mnemonic !== undefined; - - return { - // Compute the stable entropy source ID from the keyring's mnemonic (BIP-39 seed). - entropySourceId: await hdKeyring.toEntropySourceId(), - // No need to include the mnemonic here if we're not exporting secrets. - mnemonic: includeMnemonic ? hdKeyring.mnemonic : undefined, - }; - }, - ); - const { entropySourceId, mnemonic } = result as { - entropySourceId: string; - mnemonic?: Uint8Array; - }; - - // We use the stable entropy source ID as the payload wallet ID, rather than the local wallet ID, to - // ensure that the exported snapshot is stable across different installations and sessions. - const wallet: AccountWalletMnemonicPayload = { - type: AccountWalletPayloadType.Mnemonic, - id: toWalletPayloadId(entropySourceId), - metadata: { name: walletObj.metadata.name }, - groups: [], - }; - - idMap.add(walletObj.id, wallet.id); - - for (const groupObj of Object.values(walletObj.groups)) { - const { groupIndex } = groupObj.metadata.entropy; - - const group: AccountWalletMnemonicGroupEntry = { - id: toGroupPayloadId(wallet.id, groupIndex), - groupIndex, - metadata: { - name: groupObj.metadata.name, - pinned: groupObj.metadata.pinned, - hidden: groupObj.metadata.hidden, - }, - }; - - idMap.add(groupObj.id, group.id); - - wallet.groups.push(group); - } - - // Sort the groups by their `groupIndex` to ensure a stable order in the exported payload. - wallet.groups.sort( - (group, otherGroup) => group.groupIndex - otherGroup.groupIndex, - ); - - // Defensive check: group indices must be contiguous starting at 0. This should never happen - // in practice since groups are only ever appended, but guards against future regressions - // producing a payload that silently fails validation on import. - for (let i = 0; i < wallet.groups.length; i++) { - if (wallet.groups[i]?.groupIndex !== i) { - throw new Error('Found non-contiguous groups in mnemonic wallet'); - } - } - - if (includeSecrets) { - if (mnemonic === undefined) { - throw new Error(`Failed to export mnemonic for wallet ${wallet.id}`); - } - - wallet.value = encodeBytes(mnemonic); - } - - return wallet; -} - -/** - * Exports a single simple-keyring wallet object as an {@link AccountWalletPrivateKeyPayload}. - * - * All groups from the wallet are merged into the `'private-key'` singleton payload entry. - * When `includeSecrets` is `true`, each group's private key is exported via - * `KeyringController:withKeyringV2`. - * - * @param context - Export context. - * @param walletObj - The local simple-keyring wallet to export. - * @param includeSecrets - When `true`, private keys are included in the payload. - * @param idMap - ID map to populate with local↔payload ID pairs for this wallet and its groups. - * @returns The private-key wallet payload entry. - * @throws If `includeSecrets` is `true` but a private key cannot be exported for an account. - */ -async function exportPrivateKeyWalletObject( - context: ExportContext, - walletObj: AccountWalletKeyringObject, - includeSecrets: boolean, - idMap: IdMap, -): Promise { - // We use a singleton wallet ID for private keys. - const wallet: AccountWalletPrivateKeyPayload = { - type: AccountWalletPayloadType.PrivateKey, - id: toWalletPayloadId(AccountWalletPayloadType.PrivateKey), - metadata: { name: walletObj.metadata.name }, - groups: [], - }; - - for (const groupObj of Object.values(walletObj.groups)) { - const accountId = groupObj.accounts[0]; - if (!accountId) { - continue; - } - const account = context.messenger.call( - 'AccountsController:getAccount', - accountId, - ); - if (!account) { - continue; - } - - const { address } = account; - - let exported: PrivateKeyExportedAccount | undefined; - if (includeSecrets) { - const result = await context.messenger.call( - 'KeyringController:withKeyringV2', - { address }, - async ({ keyring }) => { - if (!keyring.exportAccount) { - throw new Error( - `Keyring for account ${accountId} does not support exportAccount`, - ); - } - - return keyring.exportAccount(accountId, { - type: 'private-key', - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }); - }, - ); - - exported = result as PrivateKeyExportedAccount; - } - - const group: AccountWalletPrivateKeyGroupEntry = { - id: toGroupPayloadId(wallet.id, address), - metadata: { - name: groupObj.metadata.name, - pinned: groupObj.metadata.pinned, - hidden: groupObj.metadata.hidden, - }, - }; - - if (includeSecrets) { - if (!exported) { - throw new Error( - `Failed to export private key for account ${accountId}`, - ); - } - group.value = { - privateKey: encodeBytes(new TextEncoder().encode(exported.privateKey)), - encoding: exported.encoding, - type: EthAccountType.Eoa, - }; - } - - idMap.add(groupObj.id, group.id); - - wallet.groups.push(group); - } - - return wallet; -} - -/** - * Builds an {@link AccountTreeSnapshot} from the current controller state. - * - * Iterates over all wallets in the tree: - * - {@link AccountWalletType.Entropy} (HD) wallets -> `'mnemonic'` payload entries. - * - {@link AccountWalletType.Keyring} wallets of type `simple` -> `'private-key'` payload entries. - * - Snap wallets and hardware keyrings are skipped in v1. - * - * @param context - Export context providing state and messenger access. - * @param options - Export options. - * @returns A promise that resolves to the built snapshot. - * @throws If the vault is locked. - */ -export async function exportState( - context: ExportContext, - options: ExportStateOptions = {}, -): Promise { - const state = context.getState(); - - const { isUnlocked } = context.messenger.call('KeyringController:getState'); - if (!isUnlocked) { - throw new Error('Cannot export account tree when vault is locked'); - } - - // Use `options` here to let the compiler infer the type of `includeSecrets` based on the - // discriminated union. - if (options.includeSecrets) { - // We verify the password here to force consumers to have it in their flow - // before calling exportState. The password is never stored, so the only - // way to supply it is to ask the user, ensuring they are prompted upstream - // rather than having the export silently succeed without their interaction. - await context.messenger.call( - 'KeyringController:verifyPassword', - options.password, - ); - } - - const includeSecrets = options.includeSecrets ?? false; - - const idMap = new IdMap(); - const entries: AccountTreeWalletEntry[] = []; - let privateKeyWallet: AccountWalletPrivateKeyPayload | undefined; - - for (const walletObj of Object.values(state.accountTree.wallets)) { - if (isMnemonicWalletObject(walletObj)) { - entries.push( - await exportMnemonicWalletObject( - context, - walletObj, - includeSecrets, - idMap, - ), - ); - } else if (isPrivateKeyWalletObject(walletObj)) { - const exported = await exportPrivateKeyWalletObject( - context, - walletObj, - includeSecrets, - idMap, - ); - - if (privateKeyWallet) { - privateKeyWallet.groups.push(...exported.groups); - } else { - privateKeyWallet = exported; - - // Register only once, since all private keys are exported into the same wallet entry. - idMap.add(walletObj.id, exported.id); - } - } else { - // AccountWalletType.Snap and hardware keyrings: skipped for now. - } - } - - if (privateKeyWallet) { - entries.push(privateKeyWallet); - } - - return new AccountTreeSnapshot(entries, idMap); -} diff --git a/packages/account-tree-controller/src/state/import.test.ts b/packages/account-tree-controller/src/state/import.test.ts deleted file mode 100644 index 9acfdf2ec51..00000000000 --- a/packages/account-tree-controller/src/state/import.test.ts +++ /dev/null @@ -1,878 +0,0 @@ -import { - AccountWalletType, - toAccountGroupId, - toAccountWalletId, - toMultichainAccountGroupId, - toMultichainAccountWalletId, -} from '@metamask/account-api'; -import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; -import { EthAccountType } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -import type { - AccountTreeControllerMessenger, - AccountTreeControllerState, -} from '../types.js'; -import type { ImportContext } from './import.js'; -import { importState } from './import.js'; -import { AccountWalletPrivateKeyEncoding } from './payload.js'; -import { AccountTreeSnapshot } from './snapshot.js'; -import { - makeAccountTreePayload, - makeLocalKeyringWallet, - makeLocalMnemonicWallet, - makePayloadMnemonicWallet, - makePayloadPrivateKeyWallet, -} from './tests/helpers.js'; -import { encodeBytes } from './utils.js'; - -// Valid 20-byte hex addresses for use with getUUIDFromAddressOfNormalAccount. -const ADDR_A = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; -const ADDR_B = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; -const ADDR_C = '0xcccccccccccccccccccccccccccccccccccccccc'; - -const MOCK_ENTROPY_ID = 'mock-entropy-id'; -const MOCK_HD_WALLET_ID = toMultichainAccountWalletId(MOCK_ENTROPY_ID); -const MOCK_HD_GROUP_ID_0 = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 0); -const MOCK_HD_GROUP_ID_1 = toMultichainAccountGroupId(MOCK_HD_WALLET_ID, 1); -const MOCK_PRIVATE_KEY_WALLET_ID = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, -); - -const TEST_MNEMONIC = encodeBytes( - new TextEncoder().encode( - 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', - ), -); -const MOCK_PRIVATE_KEY_HEX_STRING = '0xdeadbeef'; - -const MOCK_PRIVATE_KEY_B58_STRING = '5Kb8kLf9z...'; -const MOCK_PRIVATE_KEY_B58_BYTES = encodeBytes( - new TextEncoder().encode(MOCK_PRIVATE_KEY_B58_STRING), -); - -function makeMnemonicWalletState(): AccountTreeControllerState['accountTree']['wallets'] { - return makeLocalMnemonicWallet(MOCK_ENTROPY_ID, [ - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, - { groupIndex: 1, name: 'Account 2', accounts: ['account-2'] }, - ]); -} - -function makeMnemonicWalletStateWithOneGroup(): AccountTreeControllerState['accountTree']['wallets'] { - return makeLocalMnemonicWallet(MOCK_ENTROPY_ID, [ - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, - ]); -} - -const MNEMONIC_PAYLOAD = makeAccountTreePayload( - makePayloadMnemonicWallet(MOCK_ENTROPY_ID, 'My Renamed Wallet', [ - { groupIndex: 0, name: 'Renamed Account 1', pinned: true }, - { groupIndex: 1, name: 'Renamed Account 2', hidden: true }, - ]), -); - -/** - * Creates an ImportContext with individual jest mocks per action. - * - * `walletsRef.current` can be mutated by tests to simulate state changes that - * happen during an import (e.g., wallet creation events updating the tree). - * - * @param options - Setup options. - * @param options.wallets - Initial wallet state (default: empty). - * @returns context, mocks (per-action jest.fn()s), and the mutable walletsRef. - */ -function setup({ - wallets = {} as AccountTreeControllerState['accountTree']['wallets'], -}: { - wallets?: AccountTreeControllerState['accountTree']['wallets']; -} = {}): { - context: ImportContext; - /* eslint-disable @typescript-eslint/naming-convention */ - mocks: { - KeyringController: { - withKeyringV2Unsafe: jest.Mock; - withController: jest.Mock; - }; - MultichainAccountService: { - createMultichainAccountWallet: jest.Mock; - createMultichainAccountGroups: jest.Mock; - }; - setters: { - setWalletName: jest.Mock; - setAccountGroupName: jest.Mock; - setAccountGroupPinned: jest.Mock; - setAccountGroupHidden: jest.Mock; - }; - }; - /* eslint-enable @typescript-eslint/naming-convention */ - walletsRef: { current: AccountTreeControllerState['accountTree']['wallets'] }; -} { - const walletsRef = { current: wallets }; - - const mocks = { - KeyringController: { - withKeyringV2Unsafe: jest.fn(), - withController: jest.fn(), - }, - MultichainAccountService: { - createMultichainAccountWallet: jest.fn(), - createMultichainAccountGroups: jest.fn().mockResolvedValue(undefined), - }, - setters: { - setWalletName: jest.fn(), - setAccountGroupName: jest.fn(), - setAccountGroupPinned: jest.fn(), - setAccountGroupHidden: jest.fn(), - }, - }; - - const messenger = { - call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { - switch (action) { - case 'KeyringController:withKeyringV2Unsafe': - return mocks.KeyringController.withKeyringV2Unsafe(...args); - case 'KeyringController:withController': - return mocks.KeyringController.withController(...args); - case 'MultichainAccountService:createMultichainAccountWallet': - return mocks.MultichainAccountService.createMultichainAccountWallet( - ...args, - ); - case 'MultichainAccountService:createMultichainAccountGroups': - return mocks.MultichainAccountService.createMultichainAccountGroups( - ...args, - ); - default: - return undefined; - } - }), - } as unknown as AccountTreeControllerMessenger; - - const context: ImportContext = { - getState: () => ({ - accountTree: { wallets: walletsRef.current }, - selectedAccountGroup: '', - isAccountTreeSyncingInProgress: false, - hasAccountTreeSyncingSyncedAtLeastOnce: false, - accountGroupsMetadata: {}, - accountWalletsMetadata: {}, - }), - messenger, - setWalletName: mocks.setters.setWalletName, - setAccountGroupName: mocks.setters.setAccountGroupName, - setAccountGroupPinned: mocks.setters.setAccountGroupPinned, - setAccountGroupHidden: mocks.setters.setAccountGroupHidden, - }; - - return { context, mocks, walletsRef }; -} - -function makeWithKeyringV2UnsafeMock(keyring: unknown): jest.Mock { - return jest - .fn() - .mockImplementation( - async (_selector: unknown, fn: (ctx: { keyring: unknown }) => unknown) => - fn({ keyring }), - ); -} - -type WithControllerFn = (ctx: { - keyrings: { keyring: { type: string }; keyringV2: unknown }[]; - addNewKeyring: jest.Mock; -}) => Promise; - -function makeWithControllerMock({ - existingKeyringV2, - newKeyringV2, -}: { - existingKeyringV2?: unknown; - newKeyringV2?: unknown; -} = {}): jest.Mock { - return jest.fn().mockImplementation(async (fn: WithControllerFn) => { - const keyrings = existingKeyringV2 - ? [ - { - keyring: { type: KeyringTypes.simple }, - keyringV2: existingKeyringV2, - }, - ] - : []; - const addNewKeyring = jest.fn().mockResolvedValue({ - keyring: { type: KeyringTypes.simple }, - keyringV2: newKeyringV2, - }); - return fn({ keyrings, addNewKeyring }); - }); -} - -async function importSnapshot( - context: ImportContext, - payload: unknown, -): ReturnType { - return importState(context, await AccountTreeSnapshot.deserialize(payload)); -} - -describe('importState', () => { - beforeEach(() => { - jest.resetAllMocks(); - }); - - describe('mnemonic wallets', () => { - it('applies metadata to existing groups when the wallet already exists locally', async () => { - const { context, mocks } = setup({ wallets: makeMnemonicWalletState() }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => MOCK_ENTROPY_ID, - }, - ); - - await importSnapshot(context, MNEMONIC_PAYLOAD); - - expect(mocks.setters.setWalletName).toHaveBeenCalledWith( - MOCK_HD_WALLET_ID, - 'My Renamed Wallet', - ); - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - 'Renamed Account 1', - ); - expect(mocks.setters.setAccountGroupPinned).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - true, - ); - expect(mocks.setters.setAccountGroupHidden).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - false, - ); - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - 'Renamed Account 2', - ); - expect(mocks.setters.setAccountGroupPinned).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - false, - ); - expect(mocks.setters.setAccountGroupHidden).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - true, - ); - }); - - it('skips non-mnemonic wallets when searching for a matching entropy source', async () => { - const privateKeyOnlyWallets = makeLocalKeyringWallet( - KeyringTypes.simple, - [], - 'Imported', - ); - const { context, mocks } = setup({ wallets: privateKeyOnlyWallets }); - - // No mnemonic in payload -> will early-return after not finding the wallet. - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet('entropy-only', 'X', []), - ), - ); - expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); - }); - - it('skips import when no local wallet matches and no mnemonic is in the payload', async () => { - const { context, mocks } = setup({ wallets: makeMnemonicWalletState() }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => 'different-entropy-id', - }, - ); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet('unknown-entropy', 'Unknown', []), - ), - ); - expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); - }); - - it('throws when createMultichainAccountWallet returns an id not found in state', async () => { - const { context, mocks } = setup(); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => 'no-match-entropy', - }, - ); - mocks.MultichainAccountService.createMultichainAccountWallet.mockResolvedValue( - { - id: 'entropy:wallet-that-does-not-exist', - }, - ); - - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet('no-match-entropy', 'Wallet', [], { - mnemonic: TEST_MNEMONIC, - }), - ), - ), - ).rejects.toThrow('wallet not found after creation'); - }); - - it('throws when the wallet found after creation is not a mnemonic wallet', async () => { - const { context, mocks, walletsRef } = setup(); - const fakeWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, - ); - - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => 'no-match', - }, - ); - mocks.MultichainAccountService.createMultichainAccountWallet.mockImplementation( - async () => { - // Inject a keyring wallet (not entropy) at the returned ID. - walletsRef.current = makeLocalKeyringWallet( - KeyringTypes.simple, - [], - 'Not Mnemonic', - ); - return { id: fakeWalletId }; - }, - ); - - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet('no-match', 'Wallet', [], { - mnemonic: TEST_MNEMONIC, - }), - ), - ), - ).rejects.toThrow("wallet is not of type 'mnemonic'"); - }); - - it('creates a new HD wallet when not found locally and mnemonic is provided', async () => { - const { context, mocks, walletsRef } = setup(); - - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => MOCK_ENTROPY_ID, - }, - ); - mocks.MultichainAccountService.createMultichainAccountWallet.mockImplementation( - async () => { - walletsRef.current = makeMnemonicWalletState(); - return { id: MOCK_HD_WALLET_ID }; - }, - ); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet( - 'unknown-entropy', - 'My Renamed Wallet', - [{ groupIndex: 0, name: 'Account 1' }], - { mnemonic: TEST_MNEMONIC }, - ), - ), - ); - - expect( - mocks.MultichainAccountService.createMultichainAccountWallet, - ).toHaveBeenCalledWith(expect.objectContaining({ type: 'import' })); - expect(mocks.setters.setWalletName).toHaveBeenCalledWith( - MOCK_HD_WALLET_ID, - 'My Renamed Wallet', - ); - }); - - it('creates missing groups at the end of the payload list', async () => { - const { context, mocks } = setup({ - wallets: makeMnemonicWalletStateWithOneGroup(), - }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => MOCK_ENTROPY_ID, - }, - ); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet(MOCK_ENTROPY_ID, 'Wallet 1', [ - { groupIndex: 0, name: 'Account 1' }, - { groupIndex: 1, name: 'Account 2', pinned: true }, - ]), - ), - ); - - expect( - mocks.MultichainAccountService.createMultichainAccountGroups, - ).toHaveBeenCalledWith( - expect.objectContaining({ - entropySource: MOCK_ENTROPY_ID, - fromGroupIndex: 1, - toGroupIndex: 1, - }), - ); - }); - - it('applies metadata to a newly created group via the post-creation pass', async () => { - const { context, mocks, walletsRef } = setup({ - wallets: makeMnemonicWalletStateWithOneGroup(), - }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { toEntropySourceId: async () => MOCK_ENTROPY_ID }, - ); - mocks.MultichainAccountService.createMultichainAccountGroups.mockImplementation( - async () => { - // Simulate group 1 appearing in the wallet tree after creation. - walletsRef.current = makeMnemonicWalletState(); - }, - ); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet(MOCK_ENTROPY_ID, 'Wallet 1', [ - { groupIndex: 0, name: 'Account 1' }, - { groupIndex: 1, name: 'New Account', pinned: true }, - ]), - ), - ); - - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - 'New Account', - ); - expect(mocks.setters.setAccountGroupPinned).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - true, - ); - }); - - it('applies metadata to pre-existing groups even when group creation throws', async () => { - const { context, mocks } = setup({ - wallets: makeMnemonicWalletStateWithOneGroup(), - }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { toEntropySourceId: async () => MOCK_ENTROPY_ID }, - ); - mocks.MultichainAccountService.createMultichainAccountGroups.mockRejectedValue( - new Error('Snap keyring not ready'), - ); - - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet(MOCK_ENTROPY_ID, 'Wallet 1', [ - { groupIndex: 0, name: 'Renamed Account 1', pinned: true }, - { groupIndex: 1, name: 'New Account' }, - ]), - ), - ), - ).rejects.toThrow('Snap keyring not ready'); - - // Group 0 already existed locally and must have received its metadata - // before the creation loop threw. - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - 'Renamed Account 1', - ); - expect(mocks.setters.setAccountGroupPinned).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - true, - ); - }); - - it('creates missing groups in the middle of the payload list', async () => { - const stateWithGap = makeLocalMnemonicWallet(MOCK_ENTROPY_ID, [ - { groupIndex: 0, name: 'Account 0', accounts: ['account-0'] }, - { groupIndex: 2, name: 'Account 2', accounts: ['account-2'] }, - ]); - - const { context, mocks } = setup({ wallets: stateWithGap }); - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock( - { - toEntropySourceId: async () => MOCK_ENTROPY_ID, - }, - ); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet(MOCK_ENTROPY_ID, 'Wallet 1', [ - { groupIndex: 0, name: 'Account 0' }, - { groupIndex: 1, name: 'Account 1 (missing)' }, - { groupIndex: 2, name: 'Account 2' }, - ]), - ), - ); - - expect( - mocks.MultichainAccountService.createMultichainAccountGroups, - ).toHaveBeenCalledWith( - expect.objectContaining({ fromGroupIndex: 1, toGroupIndex: 1 }), - ); - }); - }); - - it('skips metadata for a group absent from the wallet after creation without throwing', async () => { - const { context, mocks, walletsRef } = setup(); - - mocks.KeyringController.withKeyringV2Unsafe = makeWithKeyringV2UnsafeMock({ - toEntropySourceId: async () => 'no-match', - }); - mocks.MultichainAccountService.createMultichainAccountWallet.mockImplementation( - async () => { - // Wallet is created with only group 0; group 1 from the payload is absent. - walletsRef.current = makeLocalMnemonicWallet( - MOCK_ENTROPY_ID, - [{ groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }], - 'Wallet', - ); - return { id: MOCK_HD_WALLET_ID }; - }, - ); - // createMultichainAccountGroups succeeds but leaves the wallet state unchanged - // (group 1 is still absent after the call). - mocks.MultichainAccountService.createMultichainAccountGroups.mockResolvedValue( - undefined, - ); - - // Must not throw even though group 1 is absent from the wallet after creation. - expect( - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadMnemonicWallet( - 'no-match', - 'Wallet', - [ - { groupIndex: 0, name: 'Account 1' }, - { groupIndex: 1, name: 'Missing Group' }, - ], - { mnemonic: TEST_MNEMONIC }, - ), - ), - ), - ).toBeUndefined(); - // Group 0 was present and must have received its metadata. - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_0, - 'Account 1', - ); - // Group 1 was never created, so no metadata should have been applied. - expect(mocks.setters.setAccountGroupName).not.toHaveBeenCalledWith( - MOCK_HD_GROUP_ID_1, - 'Missing Group', - ); - }); - - describe('private-key wallets', () => { - it('applies metadata to an existing private-key account group', async () => { - const accountId = getUUIDFromAddressOfNormalAccount(ADDR_A); - const privateKeyGroupId = toAccountGroupId( - MOCK_PRIVATE_KEY_WALLET_ID, - ADDR_A, - ); - - const privateKeyWallets = makeLocalKeyringWallet(KeyringTypes.simple, [ - { address: ADDR_A, name: 'Imported 1', accounts: [accountId] }, - ]); - - const { context, mocks } = setup({ wallets: privateKeyWallets }); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { - address: ADDR_A, - name: 'Renamed Imported', - pinned: true, - value: null, - }, - ]), - ), - ); - - // The private-key wallet name is derived from its keyring type and is not - // user-customisable, so import must never overwrite it. - expect(mocks.setters.setWalletName).not.toHaveBeenCalled(); - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - privateKeyGroupId, - 'Renamed Imported', - ); - expect(mocks.setters.setAccountGroupPinned).toHaveBeenCalledWith( - privateKeyGroupId, - true, - ); - expect(mocks.setters.setAccountGroupHidden).toHaveBeenCalledWith( - privateKeyGroupId, - false, - ); - }); - - it('creates a new simple keyring when none exists (onboarding)', async () => { - const newAccountId = getUUIDFromAddressOfNormalAccount(ADDR_B); - - const { context, mocks, walletsRef } = setup(); - - const keyringV2 = { - createAccounts: jest.fn().mockImplementation(async () => { - walletsRef.current = makeLocalKeyringWallet(KeyringTypes.simple, [ - { address: ADDR_B, name: 'New Import', accounts: [newAccountId] }, - ]); - return [{ id: newAccountId }]; - }), - }; - // No existingKeyringV2 -> addNewKeyring will be called. - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: keyringV2, - }); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { address: ADDR_B, name: 'New Import' }, - ]), - ), - ); - - // addNewKeyring was invoked (keyrings array was empty). - const [[fn]] = mocks.KeyringController.withController.mock.calls as [ - [WithControllerFn], - ]; - const addNewKeyring = jest.fn().mockResolvedValue({ - keyring: { type: KeyringTypes.simple }, - keyringV2, - }); - await fn({ keyrings: [], addNewKeyring }); - expect(addNewKeyring).toHaveBeenCalledWith(KeyringTypes.simple); - expect(keyringV2.createAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'private-key:import', - accountType: EthAccountType.Eoa, - privateKey: MOCK_PRIVATE_KEY_HEX_STRING, - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - ); - }); - - it('reuses the existing simple keyring when one is already present', async () => { - const newAccountId = getUUIDFromAddressOfNormalAccount(ADDR_B); - - const { context, mocks, walletsRef } = setup(); - - const keyringV2 = { - createAccounts: jest.fn().mockImplementation(async () => { - walletsRef.current = makeLocalKeyringWallet(KeyringTypes.simple, [ - { address: ADDR_B, name: 'New Import', accounts: [newAccountId] }, - ]); - return [{ id: newAccountId }]; - }), - }; - // existingKeyringV2 provided -> addNewKeyring must NOT be called. - mocks.KeyringController.withController = makeWithControllerMock({ - existingKeyringV2: keyringV2, - }); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { address: ADDR_B, name: 'New Import' }, - ]), - ), - ); - - // addNewKeyring was NOT invoked (existing keyring was reused). - const [[fn]] = mocks.KeyringController.withController.mock.calls as [ - [WithControllerFn], - ]; - const addNewKeyring = jest.fn(); - await fn({ - keyrings: [{ keyring: { type: KeyringTypes.simple }, keyringV2 }], - addNewKeyring, - }); - expect(addNewKeyring).not.toHaveBeenCalled(); - expect(keyringV2.createAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'private-key:import', - accountType: EthAccountType.Eoa, - privateKey: MOCK_PRIVATE_KEY_HEX_STRING, - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - ); - }); - - it('imports a private key when the account does not exist locally', async () => { - const newAccountId = getUUIDFromAddressOfNormalAccount(ADDR_B); - const privateKeyGroupId = toAccountGroupId( - MOCK_PRIVATE_KEY_WALLET_ID, - ADDR_B, - ); - - const { context, mocks, walletsRef } = setup(); - - // Simulate the wallet tree being updated during the import and the - // new account being returned by createAccounts. - const keyringV2 = { - createAccounts: jest.fn().mockImplementation(async () => { - walletsRef.current = makeLocalKeyringWallet(KeyringTypes.simple, [ - { address: ADDR_B, name: 'New Import', accounts: [newAccountId] }, - ]); - return [{ id: newAccountId }]; - }), - }; - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: keyringV2, - }); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { address: ADDR_B, name: 'New Import' }, - ]), - ), - ); - - expect(mocks.KeyringController.withController).toHaveBeenCalledWith( - expect.any(Function), - ); - expect(keyringV2.createAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'private-key:import', - accountType: EthAccountType.Eoa, - privateKey: MOCK_PRIVATE_KEY_HEX_STRING, - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - ); - expect(mocks.setters.setAccountGroupName).toHaveBeenCalledWith( - privateKeyGroupId, - 'New Import', - ); - }); - - it('throws when createAccounts returns an empty account list', async () => { - const { context, mocks } = setup(); - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: { createAccounts: jest.fn().mockResolvedValue([]) }, - }); - - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([{ address: ADDR_C, name: 'Fail' }]), - ), - ), - ).rejects.toThrow('Failed to import private key for account'); - }); - - it('throws when the keyring has no v2 interface', async () => { - const { context, mocks } = setup(); - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: undefined, - }); - - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([{ address: ADDR_C, name: 'Fail' }]), - ), - ), - ).rejects.toThrow('Simple keyring has no v2 interface'); - }); - - it('skips a private-key group whose value carries a non-EVM type', async () => { - const { context, mocks } = setup(); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { - address: ADDR_A, - name: 'Bitcoin Account', - value: { - privateKey: MOCK_PRIVATE_KEY_B58_BYTES, - encoding: AccountWalletPrivateKeyEncoding.Base58, - type: 'bip122:p2wpkh', - }, - }, - ]), - ), - ); - expect(mocks.KeyringController.withController).not.toHaveBeenCalled(); - expect(mocks.setters.setAccountGroupName).not.toHaveBeenCalled(); - }); - - it('does not skip a private-key group whose value type is eip155:eoa', async () => { - const { context, mocks } = setup(); - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: { createAccounts: jest.fn().mockResolvedValue([]) }, - }); - - // withController is called (not skipped), but createAccounts returns [] so it throws. - await expect( - importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { - address: ADDR_A, - name: 'EVM Account', - value: { type: EthAccountType.Eoa }, - }, - ]), - ), - ), - ).rejects.toThrow('Failed to import private key for account'); - expect(mocks.KeyringController.withController).toHaveBeenCalled(); - }); - - it('skips a private-key group that has no value and account does not exist locally', async () => { - const { context, mocks } = setup(); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([ - { address: ADDR_C, name: 'Missing', value: null }, - ]), - ), - ); - expect(mocks.setters.setAccountGroupName).not.toHaveBeenCalled(); - }); - - it('skips metadata when the local group is not found after import', async () => { - const { context, mocks } = setup(); - // State stays empty -- the import succeeds but leaves no group in the tree. - mocks.KeyringController.withController = makeWithControllerMock({ - newKeyringV2: { - createAccounts: jest - .fn() - .mockResolvedValue([{ id: 'some-account-id' }]), - }, - }); - - await importSnapshot( - context, - makeAccountTreePayload( - makePayloadPrivateKeyWallet([{ address: ADDR_C, name: 'Orphan' }]), - ), - ); - expect(mocks.setters.setAccountGroupName).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/packages/account-tree-controller/src/state/import.ts b/packages/account-tree-controller/src/state/import.ts deleted file mode 100644 index c1562ffa724..00000000000 --- a/packages/account-tree-controller/src/state/import.ts +++ /dev/null @@ -1,433 +0,0 @@ -import { - AccountWalletType, - toAccountGroupId, - toAccountWalletId, - toMultichainAccountGroupId, -} from '@metamask/account-api'; -import type { AccountGroupId, AccountWalletId } from '@metamask/account-api'; -import { getUUIDFromAddressOfNormalAccount } from '@metamask/accounts-controller'; -import { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import { EthAccountType } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -import type { - AccountTreeControllerMessenger, - AccountTreeControllerState, -} from '../types.js'; -import type { - AccountWalletEntropyObject, - AccountWalletKeyringObject, -} from '../wallet.js'; -import { isMnemonicWalletObject } from './export.js'; -import type { - AccountWalletMnemonicGroupEntry, - AccountWalletMnemonicPayload, - AccountWalletPayloadId, - AccountWalletPrivateKeyGroupEntry, -} from './payload.js'; -import { - AccountWalletPayloadType, - parsePayloadGroupId, - toWalletPayloadId, -} from './payload.js'; -import type { AccountTreeSnapshot } from './snapshot.js'; -import { decodeBytes } from './utils.js'; - -/** Context required by {@link importState}. */ -export type ImportContext = { - getState: () => AccountTreeControllerState; - messenger: AccountTreeControllerMessenger; - setWalletName: (walletId: AccountWalletId, name: string) => void; - /** Sets a group name. Implementations must resolve name conflicts automatically. */ - setAccountGroupName: (groupId: AccountGroupId, name: string) => void; - setAccountGroupPinned: (groupId: AccountGroupId, pinned: boolean) => void; - setAccountGroupHidden: (groupId: AccountGroupId, hidden: boolean) => void; -}; - -/** - * Searches the local wallet tree for an entropy wallet whose stable payload ID - * matches `payloadWalletId`. The entropy source ID is derived on-the-fly via - * `KeyringController:withKeyringV2Unsafe` rather than relying on cached metadata. - * - * @param context - Import context. - * @param payloadWalletId - Payload wallet ID to match against. - * @returns The matching local entropy wallet, or `undefined` if not found. - */ -async function findLocalWalletMnemonicFromPayloadId( - context: ImportContext, - payloadWalletId: AccountWalletPayloadId, -): Promise { - const wallets = Object.values(context.getState().accountTree.wallets); - - for (const wallet of wallets) { - if (isMnemonicWalletObject(wallet)) { - const result = await context.messenger.call( - 'KeyringController:withKeyringV2Unsafe', - { id: wallet.metadata.entropy.id }, - async ({ keyring }) => { - const hdKeyring = keyring as HdKeyring; - - return toWalletPayloadId(await hdKeyring.toEntropySourceId()); - }, - ); - - const localPayloadId = result as AccountWalletPayloadId; - if (localPayloadId === payloadWalletId) { - return wallet; - } - } - } - - return undefined; -} - -/** - * Returns the local entropy wallet for the given ID. - * - * @param context - Import context. - * @param id - Local wallet ID. - * @returns The entropy wallet object. - * @throws If the wallet is not found or is not an entropy wallet. - */ -function findLocalWalletMnemonicFromId( - context: ImportContext, - id: AccountWalletId, -): AccountWalletEntropyObject { - const localWallets = context.getState().accountTree.wallets; - const localWallet = localWallets[id]; - - if (!localWallet) { - throw new Error( - `Failed to import mnemonic wallet: wallet not found after creation`, - ); - } - if (!isMnemonicWalletObject(localWallet)) { - throw new Error( - `Failed to import mnemonic wallet: wallet is not of type 'mnemonic'`, - ); - } - return localWallet; -} - -/** - * Returns the local simple-keyring wallet, or `undefined` if none exists yet. - * - * The private-key wallet ID is static (derived solely from {@link KeyringTypes.simple}), - * so no runtime argument is needed to locate it. - * - * @param context - Import context. - * @returns The keyring wallet object, or `undefined` if not present in state. - */ -function findLocalWalletPrivateKey( - context: ImportContext, -): AccountWalletKeyringObject | undefined { - const localWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, - ); - const localWallet = context.getState().accountTree.wallets[localWalletId]; - return localWallet?.type === AccountWalletType.Keyring - ? localWallet - : undefined; -} - -/** - * Returns `true` if the given payload group is already present in the local simple-keyring wallet. - * - * @param localWallet - The local simple-keyring wallet, or `undefined` if none exists yet. - * @param group - The payload group entry to check. - * @returns Whether the group's account is already tracked locally. - */ -function hasLocalGroupPrivateKey( - localWallet: AccountWalletKeyringObject | undefined, - group: AccountWalletPrivateKeyGroupEntry, -): boolean { - const address = parsePayloadGroupId(group.id).subId; - - const localWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, - ); - const localGroupId = toAccountGroupId(localWalletId, address); - const localGroup = localWallet?.groups[localGroupId]; - - const accountId = getUUIDFromAddressOfNormalAccount(address); - - return localGroup?.accounts.some((id) => id === accountId) ?? false; -} - -/** - * Applies name, pinned, and hidden metadata from a payload group entry to a local group. - * - * @param context - Import context providing the metadata setters. - * @param localGroupId - Local group ID to update. - * @param payloadGroupMetadata - Metadata from the payload group entry. - */ -function setGroupMetadata( - context: ImportContext, - localGroupId: AccountGroupId, - payloadGroupMetadata: AccountWalletMnemonicGroupEntry['metadata'], -): void { - context.setAccountGroupName(localGroupId, payloadGroupMetadata.name); - context.setAccountGroupPinned(localGroupId, payloadGroupMetadata.pinned); - context.setAccountGroupHidden(localGroupId, payloadGroupMetadata.hidden); -} - -/** - * Computes the contiguous ranges of group indices that are present in the payload - * but absent from the local wallet, so they can be created in batches. - * - * @param localWallet - The local entropy wallet to check existing groups against. - * @param payloadGroups - Ordered group entries from the payload. - * @returns An array of `[fromGroupIndex, toGroupIndex]` ranges to create. - */ -function getRangesFromPayloadGroups( - localWallet: AccountWalletEntropyObject, - payloadGroups: AccountWalletMnemonicGroupEntry[], -): [number, number][] { - let rangeIndex: number | undefined; - const ranges: [number, number][] = []; - - // Keep track of the last payload group so we can close the final range if needed. - let lastPayloadGroup: AccountWalletMnemonicGroupEntry | undefined; - for (const payloadGroup of payloadGroups) { - const localGroupId = toMultichainAccountGroupId( - localWallet.id, - payloadGroup.groupIndex, - ); - - if (localWallet.groups[localGroupId]) { - if (rangeIndex !== undefined) { - ranges.push([rangeIndex, payloadGroup.groupIndex - 1]); - rangeIndex = undefined; - } - continue; - } - - rangeIndex ??= payloadGroup.groupIndex; - lastPayloadGroup = payloadGroup; - } - - if (rangeIndex !== undefined && lastPayloadGroup !== undefined) { - ranges.push([rangeIndex, lastPayloadGroup.groupIndex]); - } - - return ranges; -} - -/** - * Applies a mnemonic wallet payload entry to the local state. - * - * If no local wallet with the same entropy source ID exists and a mnemonic is - * present in the payload, a new HD wallet is created via - * `MultichainAccountService:createMultichainAccountWallet`. Missing groups are - * created in batches via `MultichainAccountService:createMultichainAccountGroups`. - * Metadata (name, pinned, hidden) is applied to all groups afterward. - * - * @param context - Import context. - * @param payloadWallet - The mnemonic wallet entry from the payload. - */ -async function importMnemonicWallet( - context: ImportContext, - payloadWallet: AccountWalletMnemonicPayload, -): Promise { - // Find the local wallet with the same entropy source ID if it exists. - let localWallet = await findLocalWalletMnemonicFromPayloadId( - context, - payloadWallet.id, - ); - - if (!localWallet) { - if (!payloadWallet.value) { - // No mnemonic in payload and wallet doesn't exist locally -- nothing to do. - return; - } - - // Import the mnemonic as a new HD wallet. - const mnemonic = decodeBytes(payloadWallet.value); - const { id } = await context.messenger.call( - 'MultichainAccountService:createMultichainAccountWallet', - { type: 'import', mnemonic }, - ); - - // Event handlers fire synchronously, so the wallet is in the tree now. - localWallet = findLocalWalletMnemonicFromId(context, id); - } - - context.setWalletName(localWallet.id, payloadWallet.metadata.name); - - // Apply metadata to groups that are already present locally before attempting - // to create missing ones. If createMultichainAccountGroups throws partway - // through, these groups would otherwise be left without their payload metadata. - const localExistingGroupIds = new Set(Object.keys(localWallet.groups)); - for (const payloadGroup of payloadWallet.groups) { - const localGroupId = toMultichainAccountGroupId( - localWallet.id, - payloadGroup.groupIndex, - ); - if (localExistingGroupIds.has(localGroupId)) { - setGroupMetadata(context, localGroupId, payloadGroup.metadata); - } - } - - for (const range of getRangesFromPayloadGroups( - localWallet, - payloadWallet.groups, - )) { - await context.messenger.call( - 'MultichainAccountService:createMultichainAccountGroups', - { - entropySource: localWallet.metadata.entropy.id, - fromGroupIndex: range[0], - toGroupIndex: range[1], - }, - ); - } - - // Re-read wallet after groups creation. - localWallet = findLocalWalletMnemonicFromId(context, localWallet.id); - - // Apply metadata to newly created groups. Skip groups that were already - // handled above, and guard against groups that failed to be created so a - // partial failure doesn't throw and abort the rest of the snapshot import. - for (const payloadGroup of payloadWallet.groups) { - const localGroupId = toMultichainAccountGroupId( - localWallet.id, - payloadGroup.groupIndex, - ); - if ( - localExistingGroupIds.has(localGroupId) || - !localWallet.groups[localGroupId] - ) { - continue; - } - setGroupMetadata(context, localGroupId, payloadGroup.metadata); - } -} - -/** - * Applies private-key wallet group entries from the payload to the local state. - * - * For each group the account address is derived from the payload group ID. If the - * account does not yet exist locally and a private key is provided, it is imported - * via `KeyringController:withKeyringV2` using the `'private-key:import'` constructor. - * Metadata (name, pinned, hidden) is then applied to the local group. - * - * @param context - Import context. - * @param payloadGroups - Private-key group entries from the payload. - */ -async function importPrivateKeyWallet( - context: ImportContext, - payloadGroups: AccountWalletPrivateKeyGroupEntry[], -): Promise { - const localWalletId = toAccountWalletId( - AccountWalletType.Keyring, - KeyringTypes.simple, - ); - - // Only EVM EOA accounts are supported for now. Non-EVM private keys require - // Snap-based import routing (ADR-0007), which is not yet implemented. Skip the - // entire entry so payloads from future clients are accepted without crashing. - // In the same pass, collect groups whose key is not yet present locally. - const localWallet = findLocalWalletPrivateKey(context); - const supportedGroups: AccountWalletPrivateKeyGroupEntry[] = []; - const missingGroups: AccountWalletPrivateKeyGroupEntry[] = []; - - for (const group of payloadGroups) { - const type = group.value?.type; - if (type !== undefined && type !== EthAccountType.Eoa) { - continue; - } - - supportedGroups.push(group); - - if ( - !hasLocalGroupPrivateKey(localWallet, group) && - group.value !== undefined - ) { - missingGroups.push(group); - } - } - - // Import all missing keys in a single :withController call to avoid - // triggering multiple `KeyringController:stateChanged` events. - if (missingGroups.length > 0) { - await context.messenger.call( - 'KeyringController:withController', - async (controller) => { - // Find the existing simple keyring or create one if this is the - // first private-key import (e.g. during onboarding). - const existing = controller.keyrings.find( - ({ keyring }) => keyring.type === KeyringTypes.simple, - ); - const { keyringV2 } = - existing ?? (await controller.addNewKeyring(KeyringTypes.simple)); - - if (!keyringV2) { - throw new Error('Simple keyring has no v2 interface'); - } - - for (const group of missingGroups) { - // Safe to assert `group.value` is present because we filtered out groups - // without a `value` above. - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { privateKey: privateKeyBytes, encoding } = group.value!; - const privateKey = new TextDecoder().decode( - decodeBytes(privateKeyBytes), - ); - const [account] = await keyringV2.createAccounts({ - type: 'private-key:import', - accountType: EthAccountType.Eoa, - privateKey, - encoding, - }); - - if (!account) { - throw new Error('Failed to import private key for account'); - } - } - }, - ); - } - - // Re-read state once after all imports, then apply metadata to every group - // that now exists locally. - for (const group of supportedGroups) { - const address = parsePayloadGroupId(group.id).subId; - const localGroupId = toAccountGroupId(localWalletId, address); - const localGroup = findLocalWalletPrivateKey(context)?.groups[localGroupId]; - if (!localGroup) { - continue; - } - setGroupMetadata(context, localGroup.id, group.metadata); - } -} - -/** - * Applies an {@link AccountTreeSnapshot} to the current controller state. - * - * The snapshot must already have been validated — typically via - * {@link AccountTreeSnapshot.deserialize}. For each retained wallet: - * - * - `'mnemonic'`: imports the mnemonic when provided and not already present, - * then applies metadata to all groups. - * - `'private-key'`: imports each retained group's key when provided and not - * already present, then applies metadata. - * - * @param context - Import context providing state, messenger, and setters. - * @param snapshot - The validated snapshot to import. - */ -export async function importState( - context: ImportContext, - snapshot: AccountTreeSnapshot, -): Promise { - const payload = snapshot.serialize(); - - for (const wallet of payload.wallets) { - if (wallet.type === AccountWalletPayloadType.Mnemonic) { - await importMnemonicWallet(context, wallet); - } else { - await importPrivateKeyWallet(context, wallet.groups); - } - } -} diff --git a/packages/account-tree-controller/src/state/payload.ts b/packages/account-tree-controller/src/state/payload.ts index 3b1311edeab..1086d803069 100644 --- a/packages/account-tree-controller/src/state/payload.ts +++ b/packages/account-tree-controller/src/state/payload.ts @@ -209,18 +209,10 @@ export function toGroupPayloadId( } /** Options accepted by {@link AccountTreeController.exportState}. */ -export type ExportStateOptions = - | { - /** When `true`, secrets (mnemonic / private keys) are included in the snapshot. */ - includeSecrets: true; - /** Password verified against the vault before any secret is read. */ - password: string; - } - | { - /** When `false` or omitted, only metadata is exported — no password needed. */ - includeSecrets?: false; - password?: never; - }; +export type ExportStateOptions = { + /** When `true`, secrets (mnemonic / private keys) are included. Requires the vault to be unlocked. */ + includeSecrets?: boolean; +}; const AccountWalletPayloadIdStruct = define( 'AccountWalletPayloadId', diff --git a/packages/account-tree-controller/src/state/roundtrip.test.ts b/packages/account-tree-controller/src/state/roundtrip.test.ts deleted file mode 100644 index fe067745243..00000000000 --- a/packages/account-tree-controller/src/state/roundtrip.test.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * Round-trip integration tests for the export, serialize, deserialize, import pipeline. - * - * These tests exist to catch encoding/decoding mismatches that slip past unit tests because - * the unit tests for export and import are isolated: export mocks never hit the struct - * validator, and import fixtures are built directly without going through the encoder. - * - * A failure here means the wire format produced by `exportState` cannot be consumed by - * `importState` without data loss, which is the central correctness property of this package. - */ -import { toMultichainAccountWalletId } from '@metamask/account-api'; -import { EthAccountType } from '@metamask/keyring-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -import type { - AccountTreeControllerMessenger, - AccountTreeControllerState, -} from '../types.js'; -import type { ExportContext } from './export.js'; -import { exportState } from './export.js'; -import type { ImportContext } from './import.js'; -import { importState } from './import.js'; -import { AccountWalletPrivateKeyEncoding } from './payload.js'; -import { AccountTreeSnapshot } from './snapshot.js'; -import { - makeLocalKeyringWallet, - makeLocalMnemonicWallet, -} from './tests/helpers.js'; - -const MOCK_ENTROPY_ID = 'stable-entropy-id'; -const MOCK_HD_WALLET_ID = toMultichainAccountWalletId(MOCK_ENTROPY_ID); -const MOCK_HD_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] = - makeLocalMnemonicWallet(MOCK_ENTROPY_ID, [ - { groupIndex: 0, name: 'Account 1', accounts: ['account-1'] }, - ]); - -const MOCK_PRIVATE_KEY_WALLET_STATE: AccountTreeControllerState['accountTree']['wallets'] = - makeLocalKeyringWallet( - KeyringTypes.simple, - [ - { - address: '0xabc', - name: 'Imported 1', - accounts: ['account-private-key-1'], - }, - ], - 'Imported Accounts', - ); - -function makeExportContext( - wallets: AccountTreeControllerState['accountTree']['wallets'], - messengerActions: { - withKeyringV2Unsafe?: jest.Mock; - withKeyringV2?: jest.Mock; - getAccount?: jest.Mock; - } = {}, -): ExportContext { - const messenger = { - call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { - switch (action) { - case 'KeyringController:getState': - return { isUnlocked: true }; - case 'KeyringController:withKeyringV2Unsafe': - return messengerActions.withKeyringV2Unsafe?.(...args); - case 'KeyringController:withKeyringV2': - return messengerActions.withKeyringV2?.(...args); - case 'AccountsController:getAccount': - return messengerActions.getAccount?.(...args); - default: - return undefined; - } - }), - } as unknown as AccountTreeControllerMessenger; - - const state: AccountTreeControllerState = { - accountTree: { wallets }, - selectedAccountGroup: '', - isAccountTreeSyncingInProgress: false, - hasAccountTreeSyncingSyncedAtLeastOnce: false, - accountGroupsMetadata: {}, - accountWalletsMetadata: {}, - }; - - return { getState: () => state, messenger }; -} - -function makeImportContext( - walletsRef: { current: AccountTreeControllerState['accountTree']['wallets'] }, - messengerActions: { - withKeyringV2Unsafe?: jest.Mock; - withController?: jest.Mock; - createMultichainAccountWallet?: jest.Mock; - createMultichainAccountGroups?: jest.Mock; - } = {}, -): ImportContext { - const messenger = { - call: jest.fn().mockImplementation((action: string, ...args: unknown[]) => { - switch (action) { - case 'KeyringController:withKeyringV2Unsafe': - return messengerActions.withKeyringV2Unsafe?.(...args); - case 'KeyringController:withController': - return messengerActions.withController?.(...args); - case 'MultichainAccountService:createMultichainAccountWallet': - return messengerActions.createMultichainAccountWallet?.(...args); - case 'MultichainAccountService:createMultichainAccountGroups': - return ( - messengerActions.createMultichainAccountGroups?.(...args) ?? - Promise.resolve() - ); - default: - return undefined; - } - }), - } as unknown as AccountTreeControllerMessenger; - - return { - getState: () => ({ - accountTree: { wallets: walletsRef.current }, - selectedAccountGroup: '', - isAccountTreeSyncingInProgress: false, - hasAccountTreeSyncingSyncedAtLeastOnce: false, - accountGroupsMetadata: {}, - accountWalletsMetadata: {}, - }), - messenger, - setWalletName: jest.fn(), - setAccountGroupName: jest.fn(), - setAccountGroupPinned: jest.fn(), - setAccountGroupHidden: jest.fn(), - }; -} - -function makeHdKeyringUnsafeHandler( - entropySourceId: string, - mnemonic: Uint8Array | null = null, -): jest.Mock { - return jest - .fn() - .mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => - callback({ - keyring: { - toEntropySourceId: async () => entropySourceId, - mnemonic, - }, - }), - ); -} - -function makePrivateKeyExportHandler(result: { - privateKey: string; - encoding: string; -}): jest.Mock { - return jest - .fn() - .mockImplementation( - async ( - _selector: unknown, - callback: (ctx: { keyring: unknown }) => unknown, - ) => callback({ keyring: { exportAccount: async () => result } }), - ); -} - -type WithControllerFn = (ctx: { - keyrings: { keyring: { type: string }; keyringV2: unknown }[]; - addNewKeyring: jest.Mock; -}) => Promise; - -function makeWithControllerMock(keyringV2: unknown): jest.Mock { - return jest.fn().mockImplementation(async (fn: WithControllerFn) => { - const addNewKeyring = jest.fn().mockResolvedValue({ - keyring: { type: KeyringTypes.simple }, - keyringV2, - }); - return fn({ keyrings: [], addNewKeyring }); - }); -} - -describe('export -> serialize -> deserialize -> import round-trip', () => { - describe('mnemonic wallets', () => { - it('preserves raw mnemonic bytes end-to-end', async () => { - // Use a non-trivial byte sequence to catch encoding bugs (not all zeros). - const originalMnemonic = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); - - // -- Export -- - const exportCtx = makeExportContext(MOCK_HD_WALLET_STATE, { - withKeyringV2Unsafe: makeHdKeyringUnsafeHandler( - MOCK_ENTROPY_ID, - originalMnemonic, - ), - }); - const snapshot = await exportState(exportCtx, { includeSecrets: true }); - - // -- Serialize: the result must pass assertAccountTreePayload validation -- - const serialized = snapshot.serialize(); - const deserialized = await AccountTreeSnapshot.deserialize(serialized); - - // -- Import: capture the mnemonic bytes handed to createMultichainAccountWallet -- - const walletsRef = { - current: {} as AccountTreeControllerState['accountTree']['wallets'], - }; - let capturedMnemonic: Uint8Array | undefined; - const createMultichainAccountWallet = jest - .fn() - .mockImplementation(async (opts: { mnemonic: Uint8Array }) => { - capturedMnemonic = opts.mnemonic; - walletsRef.current = MOCK_HD_WALLET_STATE; - return { id: MOCK_HD_WALLET_ID }; - }); - - const importCtx = makeImportContext(walletsRef, { - // Return a different entropy ID so import creates a new wallet. - withKeyringV2Unsafe: makeHdKeyringUnsafeHandler('different-entropy-id'), - createMultichainAccountWallet, - }); - - await importState(importCtx, deserialized); - - expect(createMultichainAccountWallet).toHaveBeenCalled(); - expect(capturedMnemonic).toStrictEqual(originalMnemonic); - }); - - it('assertAccountTreePayload accepts the exported payload without error', async () => { - const exportCtx = makeExportContext(MOCK_HD_WALLET_STATE, { - withKeyringV2Unsafe: makeHdKeyringUnsafeHandler( - MOCK_ENTROPY_ID, - new Uint8Array([0xab, 0xcd]), - ), - }); - const snapshot = await exportState(exportCtx, { includeSecrets: true }); - - expect( - await AccountTreeSnapshot.deserialize(snapshot.serialize()), - ).toBeDefined(); - }); - }); - - describe('private-key wallets', () => { - it('preserves the private key string end-to-end', async () => { - const originalPrivateKey = '0xdeadbeefcafebabe'; - - // -- Export -- - const exportCtx = makeExportContext(MOCK_PRIVATE_KEY_WALLET_STATE, { - getAccount: jest - .fn() - .mockReturnValue({ id: 'account-private-key-1', address: '0xabc' }), - withKeyringV2: makePrivateKeyExportHandler({ - privateKey: originalPrivateKey, - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - }); - const snapshot = await exportState(exportCtx, { includeSecrets: true }); - - // -- Serialize + validate -- - const serialized = snapshot.serialize(); - const deserialized = await AccountTreeSnapshot.deserialize(serialized); - - // -- Import: capture the decoded private key passed to createAccounts -- - const createAccounts = jest - .fn() - .mockResolvedValue([{ id: 'new-account-private-key-1' }]); - const walletsRef = { - current: {} as AccountTreeControllerState['accountTree']['wallets'], - }; - const importCtx = makeImportContext(walletsRef, { - withController: makeWithControllerMock({ createAccounts }), - }); - - await importState(importCtx, deserialized); - - expect(createAccounts).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'private-key:import', - accountType: EthAccountType.Eoa, - privateKey: originalPrivateKey, - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - ); - }); - - it('assertAccountTreePayload accepts the exported payload without error', async () => { - const exportCtx = makeExportContext(MOCK_PRIVATE_KEY_WALLET_STATE, { - getAccount: jest - .fn() - .mockReturnValue({ id: 'account-private-key-1', address: '0xabc' }), - withKeyringV2: makePrivateKeyExportHandler({ - privateKey: '0x1234', - encoding: AccountWalletPrivateKeyEncoding.Hexadecimal, - }), - }); - const snapshot = await exportState(exportCtx, { includeSecrets: true }); - - expect( - await AccountTreeSnapshot.deserialize(snapshot.serialize()), - ).toBeDefined(); - }); - }); -}); diff --git a/packages/account-tree-controller/src/state/tests/helpers.ts b/packages/account-tree-controller/src/state/tests/helpers.ts deleted file mode 100644 index ff7c7756949..00000000000 --- a/packages/account-tree-controller/src/state/tests/helpers.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { - AccountWalletType, - toAccountGroupId, - toAccountWalletId, - toMultichainAccountGroupId, - toMultichainAccountWalletId, -} from '@metamask/account-api'; -import { AccountGroupType } from '@metamask/account-api'; -import { KeyringTypes } from '@metamask/keyring-controller'; - -import type { AccountTreeControllerState } from '../../types.js'; -import { - ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, - AccountWalletPayloadType, - AccountWalletPrivateKeyEncoding, - toGroupPayloadId, - toWalletPayloadId, -} from '../payload.js'; -import type { - AccountTreePayload, - AccountWalletMnemonicPayload, - AccountWalletPrivateKeyPayload, -} from '../payload.js'; -import { encodeBytes } from '../utils.js'; -import type { EncodedBytes } from '../utils.js'; - -const PRIVATE_KEY_WALLET_PAYLOAD_ID = toWalletPayloadId( - AccountWalletPayloadType.PrivateKey, -); - -const DEFAULT_PRIVATE_KEY_HEX_BYTES = encodeBytes( - new TextEncoder().encode('0xdeadbeef'), -); - -export type LocalMnemonicGroupSpec = { - groupIndex: number; - name: string; - accounts: string[]; - pinned?: boolean; - hidden?: boolean; -}; - -export type LocalSingleAccountGroupSpec = { - address: string; - name: string; - accounts: string[]; - pinned?: boolean; - hidden?: boolean; -}; - -export function makeLocalMnemonicWallet( - entropyId: string, - groups: LocalMnemonicGroupSpec[], - walletName = 'Wallet 1', -): AccountTreeControllerState['accountTree']['wallets'] { - const walletId = toMultichainAccountWalletId(entropyId); - return { - [walletId]: { - id: walletId, - type: AccountWalletType.Entropy, - status: 'ready', - groups: Object.fromEntries( - groups.map( - ({ groupIndex, name, accounts, pinned = false, hidden = false }) => { - const groupId = toMultichainAccountGroupId(walletId, groupIndex); - return [ - groupId, - { - id: groupId, - type: AccountGroupType.MultichainAccount, - accounts, - metadata: { - name, - entropy: { groupIndex }, - pinned, - hidden, - lastSelected: 0, - }, - }, - ]; - }, - ), - ), - metadata: { name: walletName, entropy: { id: entropyId } }, - }, - }; -} - -export function makeLocalKeyringWallet( - keyringType: KeyringTypes, - groups: LocalSingleAccountGroupSpec[], - walletName = 'Imported Accounts', -): AccountTreeControllerState['accountTree']['wallets'] { - const walletId = toAccountWalletId(AccountWalletType.Keyring, keyringType); - return { - [walletId]: { - id: walletId, - type: AccountWalletType.Keyring, - status: 'ready', - groups: Object.fromEntries( - groups.map( - ({ address, name, accounts, pinned = false, hidden = false }) => { - const groupId = toAccountGroupId(walletId, address); - return [ - groupId, - { - id: groupId, - type: AccountGroupType.SingleAccount, - accounts, - metadata: { - name, - pinned, - hidden, - lastSelected: 0, - }, - }, - ]; - }, - ), - ), - metadata: { name: walletName, keyring: { type: keyringType } }, - }, - }; -} - -export type MnemonicGroupSpec = { - groupIndex: number; - name: string; - pinned?: boolean; - hidden?: boolean; -}; - -export function makePayloadMnemonicWallet( - entropySourceId: string, - walletName: string, - groups: MnemonicGroupSpec[], - options: { mnemonic?: EncodedBytes } = {}, -): AccountWalletMnemonicPayload { - const walletId = toWalletPayloadId(entropySourceId); - return { - id: walletId, - type: AccountWalletPayloadType.Mnemonic, - ...(options.mnemonic !== undefined && { value: options.mnemonic }), - metadata: { name: walletName }, - groups: groups.map( - ({ groupIndex, name, pinned = false, hidden = false }) => ({ - id: toGroupPayloadId(walletId, groupIndex), - groupIndex, - metadata: { name, pinned, hidden }, - }), - ), - }; -} - -export type PrivateKeyGroupValue = { - privateKey?: EncodedBytes; - encoding?: AccountWalletPrivateKeyEncoding; - type?: string; -}; - -export type PrivateKeyGroupSpec = { - address: string; - name: string; - pinned?: boolean; - hidden?: boolean; - value?: PrivateKeyGroupValue | null; -}; - -export function makePayloadPrivateKeyWallet( - groups: PrivateKeyGroupSpec[], -): AccountWalletPrivateKeyPayload { - return { - id: PRIVATE_KEY_WALLET_PAYLOAD_ID, - type: AccountWalletPayloadType.PrivateKey, - metadata: { name: 'Imported Accounts' }, - groups: groups.map( - ({ address, name, pinned = false, hidden = false, value }) => { - const resolvedValue = - value === null - ? undefined - : { - privateKey: value?.privateKey ?? DEFAULT_PRIVATE_KEY_HEX_BYTES, - encoding: - value?.encoding ?? - AccountWalletPrivateKeyEncoding.Hexadecimal, - ...(value?.type !== undefined && { type: value.type }), - }; - return { - id: toGroupPayloadId(PRIVATE_KEY_WALLET_PAYLOAD_ID, address), - ...(resolvedValue !== undefined && { value: resolvedValue }), - metadata: { name, pinned, hidden }, - }; - }, - ), - }; -} - -export function makeAccountTreePayload( - ...wallets: (AccountWalletMnemonicPayload | AccountWalletPrivateKeyPayload)[] -): AccountTreePayload { - return { version: ACCOUNT_TREE_PAYLOAD_CURRENT_VERSION, wallets }; -} diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 72cbaf7ad50..4fff1d42f6d 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -14,18 +14,11 @@ import type { ControllerStateChangeEvent, } from '@metamask/base-controller'; import type { TraceCallback } from '@metamask/controller-utils'; -import type { - KeyringControllerGetStateAction, - KeyringControllerVerifyPasswordAction, - KeyringControllerWithControllerAction, - KeyringControllerWithKeyringV2Action, - KeyringControllerWithKeyringV2UnsafeAction, -} from '@metamask/keyring-controller'; +import type { KeyringControllerGetStateAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { MultichainAccountServiceCreateMultichainAccountGroupAction, MultichainAccountServiceCreateMultichainAccountGroupsAction, - MultichainAccountServiceCreateMultichainAccountWalletAction, } from '@metamask/multichain-account-service'; import type { MultichainAccountServiceWalletStatusChangeEvent } from '@metamask/multichain-account-service'; import type { @@ -93,7 +86,6 @@ export type AllowedActions = | AccountsControllerListMultichainAccountsAction | AccountsControllerSetSelectedAccountAction | KeyringControllerGetStateAction - | KeyringControllerVerifyPasswordAction | SnapControllerGetSnapAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAction @@ -102,11 +94,7 @@ export type AllowedActions = | UserStorageController.UserStorageControllerPerformBatchSetStorageAction | AuthenticationController.AuthenticationControllerGetSessionProfileAction | MultichainAccountServiceCreateMultichainAccountGroupAction - | MultichainAccountServiceCreateMultichainAccountGroupsAction - | MultichainAccountServiceCreateMultichainAccountWalletAction - | KeyringControllerWithControllerAction - | KeyringControllerWithKeyringV2Action - | KeyringControllerWithKeyringV2UnsafeAction; + | MultichainAccountServiceCreateMultichainAccountGroupsAction; export type AccountTreeControllerActions = | AccountTreeControllerGetStateAction @@ -165,27 +153,6 @@ export type AccountTreeControllerAccountGroupRemovedEvent = { payload: [AccountGroupId]; }; -/** - * Represents the `AccountTreeController:initialized` event. - * This event is emitted when the account tree has been fully built and is - * ready to consume. It carries the full controller state at the moment of - * initialization so that consumers do not need an extra `getState()` call. - */ -export type AccountTreeControllerInitializedEvent = { - type: `${typeof controllerName}:initialized`; - payload: [AccountTreeControllerState]; -}; - -/** - * Represents the `AccountTreeController:uninitialized` event. - * This event is emitted when the account tree has been torn down via - * `clearState()`, symmetric to `initialized`. - */ -export type AccountTreeControllerUninitializedEvent = { - type: `${typeof controllerName}:uninitialized`; - payload: []; -}; - export type AllowedEvents = | AccountsControllerAccountsAddedEvent | AccountsControllerAccountsRemovedEvent @@ -199,9 +166,7 @@ export type AccountTreeControllerEvents = | AccountTreeControllerSelectedAccountGroupChangeEvent | AccountTreeControllerAccountGroupCreatedEvent | AccountTreeControllerAccountGroupUpdatedEvent - | AccountTreeControllerAccountGroupRemovedEvent - | AccountTreeControllerInitializedEvent - | AccountTreeControllerUninitializedEvent; + | AccountTreeControllerAccountGroupRemovedEvent; export type AccountTreeControllerMessenger = Messenger< typeof controllerName, diff --git a/packages/account-tree-controller/tests/mockMessenger.ts b/packages/account-tree-controller/tests/mockMessenger.ts index f2c8da75371..3516c81a177 100644 --- a/packages/account-tree-controller/tests/mockMessenger.ts +++ b/packages/account-tree-controller/tests/mockMessenger.ts @@ -62,13 +62,7 @@ export function getAccountTreeControllerMessenger( 'UserStorageController:performBatchSetStorage', 'AuthenticationController:getSessionProfile', 'MultichainAccountService:createMultichainAccountGroup', - 'MultichainAccountService:createMultichainAccountGroups', - 'MultichainAccountService:createMultichainAccountWallet', 'KeyringController:getState', - 'KeyringController:verifyPassword', - 'KeyringController:withController', - 'KeyringController:withKeyringV2', - 'KeyringController:withKeyringV2Unsafe', 'SnapController:getSnap', ], }); diff --git a/packages/account-tree-controller/tsconfig.build.json b/packages/account-tree-controller/tsconfig.build.json index 7000d7ee132..d52110b5b4f 100644 --- a/packages/account-tree-controller/tsconfig.build.json +++ b/packages/account-tree-controller/tsconfig.build.json @@ -13,6 +13,5 @@ { "path": "../multichain-account-service/tsconfig.build.json" }, { "path": "../profile-sync-controller/tsconfig.build.json" } ], - "include": ["../../types", "./src"], - "exclude": ["**/*.test.ts", "**/jest.config.ts", "./src/state/tests"] + "include": ["../../types", "./src"] } diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 69e72da0887..aefa1d0c02d 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,46 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- Fix `AccountsApiDataSource` polling being served stale cached balances on roughly every other tick, since the 60s balances cache outlived the 30s poll interval ([#9926](https://github.com/MetaMask/core/pull/9926)) - -## [14.0.0] - -### Changed - -- **BREAKING:** Subscribe to `AccountTreeController:initialized` / `:uninitialized` (typed as `AccountTreeControllerInitializedEvent` / `AccountTreeControllerUninitializedEvent`) so asset tracking starts only after the account tree is fully built, instead of on intermediate `AccountTreeController:stateChange` events during `init()` or on unlock before the tree is ready ([#9892](https://github.com/MetaMask/core/pull/9892)) - - Hosts that restrict which events flow through the `AssetsController` messenger must now also delegate `AccountTreeController:initialized` and `AccountTreeController:uninitialized` -- Reduce Accounts API calls on startup and refresh: - - Skip `AccountsApiDataSource` subscribe-time initial poll after a forced `getAssets` balance fetch - - Ignore init-time `AccountTreeController:selectedAccountGroupChange` (including same-group re-emits and events before tracking starts) so `:initialized` owns first start; only refresh on real group switches while tracking - - Skip Accounts API middleware when `dataTypes` does not include `balance` (e.g. price-only refreshes) -- Bump `@metamask/config-registry-controller` from `^2.0.1` to `^3.0.0` ([#9923](https://github.com/MetaMask/core/pull/9923)) -- Bump `@metamask/network-enablement-controller` from `^6.0.3` to `^6.0.4` ([#9923](https://github.com/MetaMask/core/pull/9923)) - -## [13.1.4] - -### Changed - -- Bump `@metamask/account-tree-controller` from `^7.6.1` to `^8.0.0` ([#9886](https://github.com/MetaMask/core/pull/9886)) -- Bump `@metamask/assets-controllers` from `^111.1.0` to `^111.1.1` ([#9886](https://github.com/MetaMask/core/pull/9886)) -- Bump `@metamask/core-backend` from `^8.1.1` to `^8.1.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) - -### Fixed - -- Revert `AccountsApiDataSource` `forceUpdate` balance fetch cache window from `staleTime`/`gcTime` of `100`ms back to `0`/`0` (undoes [#9591](https://github.com/MetaMask/core/pull/9591)) so forced refreshes truly bypass the TanStack cache instead of reusing a short-lived entry ([#9870](https://github.com/MetaMask/core/pull/9870)) -- Fix Arc native USDC never appearing until the account receives its first deposit, by default-tracking the native asset id (`eip155:5042/slip44:5042`) instead of the `0x3600...` ERC20 identity so `assetsInfo` metadata is seeded up front ([#9869](https://github.com/MetaMask/core/pull/9869)) - -## [13.1.3] - ### Changed - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) -### Fixed - -- Properly filter empty (`''`) selected account group event ([#9825](https://github.com/MetaMask/core/pull/9825)) - ## [13.1.2] ### Changed @@ -930,10 +894,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@14.0.0...HEAD -[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.4...@metamask/assets-controller@14.0.0 -[13.1.4]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.3...@metamask/assets-controller@13.1.4 -[13.1.3]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.2...@metamask/assets-controller@13.1.3 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.2...HEAD [13.1.2]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.1...@metamask/assets-controller@13.1.2 [13.1.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.1.0...@metamask/assets-controller@13.1.1 [13.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@13.0.0...@metamask/assets-controller@13.1.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 0fd193a7d5a..6c6db42c245 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "14.0.0", + "version": "13.1.2", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,21 +58,21 @@ "@ethereumjs/util": "^9.1.0", "@ethersproject/abi": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^8.0.0", + "@metamask/account-tree-controller": "^7.6.1", "@metamask/accounts-controller": "^39.1.0", - "@metamask/assets-controllers": "^111.1.1", + "@metamask/assets-controllers": "^111.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", - "@metamask/config-registry-controller": "^3.0.0", + "@metamask/config-registry-controller": "^2.0.1", "@metamask/controller-utils": "^12.3.0", - "@metamask/core-backend": "^8.1.2", + "@metamask/core-backend": "^8.1.1", "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.1", "@metamask/keyring-internal-api": "^12.0.0", "@metamask/keyring-snap-client": "^10.0.0", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", - "@metamask/network-enablement-controller": "^6.0.4", + "@metamask/network-enablement-controller": "^6.0.3", "@metamask/permission-controller": "^13.1.1", "@metamask/phishing-controller": "^17.3.1", "@metamask/polling-controller": "^16.0.9", diff --git a/packages/assets-controller/src/AssetsController.test.ts b/packages/assets-controller/src/AssetsController.test.ts index febeb0d73b9..075b220955f 100644 --- a/packages/assets-controller/src/AssetsController.test.ts +++ b/packages/assets-controller/src/AssetsController.test.ts @@ -99,9 +99,8 @@ const MOCK_ASSET_ID_LOWERCASE = const MOCK_NATIVE_ASSET_ID = 'eip155:1/slip44:60' as Caip19AssetId; /** - * Activate asset tracking by marking the UI open, the keyring unlocked, and - * the account tree initialized, then flushing the async startup so the - * controller is in its running state. + * Activate asset tracking by marking the UI open and the keyring unlocked, + * then flushing the async startup so the controller is in its running state. * * @param messenger - The root messenger used to publish lifecycle events. */ @@ -112,10 +111,6 @@ async function activateTracking(messenger: RootMessenger): Promise { } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); await flushPromises(); } @@ -2688,10 +2683,6 @@ describe('AssetsController', () => { } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); await flushPromises(); @@ -2917,24 +2908,22 @@ describe('AssetsController', () => { }); describe('keyring lifecycle', () => { - it('does not start tracking on unlock until the account tree is initialized', async () => { - await withController(async ({ controller, messenger }) => { - const getAssetsSpy = jest.spyOn(controller, 'getAssets'); - + it('starts tracking on keyring unlock', async () => { + await withController(async ({ messenger }) => { messenger.publish('KeyringController:unlock'); - await flushPromises(); + await new Promise(process.nextTick); - expect(getAssetsSpy).not.toHaveBeenCalled(); - getAssetsSpy.mockRestore(); + expect(true).toBe(true); }); }); it('stops tracking on keyring lock', async () => { await withController(async ({ messenger }) => { - await activateTracking(messenger); + messenger.publish('KeyringController:unlock'); + await new Promise(process.nextTick); messenger.publish('KeyringController:lock'); - await flushPromises(); + await new Promise(process.nextTick); expect(true).toBe(true); }); @@ -2973,10 +2962,6 @@ describe('AssetsController', () => { } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); // Allow #start() -> getAssets() to resolve so the callback runs await new Promise((resolve) => setTimeout(resolve, 100)); @@ -3082,10 +3067,6 @@ describe('AssetsController', () => { } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); await flushPromises(); @@ -3116,17 +3097,13 @@ describe('AssetsController', () => { controllerOptions: { trace }, }, async ({ controller, messenger }) => { - // UI must be open, keyring unlocked, and account tree ready + // UI must be open and keyring unlocked for asset tracking to run ( messenger as unknown as { publish: (topic: string, payload?: unknown) => void; } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); await new Promise((resolve) => setTimeout(resolve, 100)); messenger.publish('KeyringController:unlock'); @@ -3406,10 +3383,6 @@ describe('AssetsController', () => { } ).publish('ClientController:stateChange', { isUiOpen: true }); messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); await flushPromises(); getAssetsSpy.mockClear(); @@ -3441,124 +3414,22 @@ describe('AssetsController', () => { }); describe('account group changes', () => { - it('refreshes assets when the selected group changes while tracking', async () => { - await withController(async ({ controller, messenger }) => { - const getAssetsSpy = jest - .spyOn(controller, 'getAssets') - .mockResolvedValue({}); - - ( - messenger as unknown as { - publish: (topic: string, payload?: unknown) => void; - } - ).publish('ClientController:stateChange', { isUiOpen: true }); - messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); - await flushPromises(); - - getAssetsSpy.mockClear(); - - (messenger.publish as CallableFunction)( - 'AccountTreeController:selectedAccountGroupChange', - 'entropy:mock-keyring-id-1/1', - 'entropy:mock-keyring-id-1/0', - ); - - await flushPromises(); - - expect(getAssetsSpy).toHaveBeenCalled(); - getAssetsSpy.mockRestore(); - }); - }); - - it('skips asset refresh when group ID is empty (onboarding or wallet reset)', async () => { - await withController(async ({ controller, messenger }) => { - const getAssetsSpy = jest.spyOn(controller, 'getAssets'); - - (messenger.publish as CallableFunction)( - 'AccountTreeController:selectedAccountGroupChange', - '', - 'entropy:mock-keyring-id-1/0', - ); - - await flushPromises(); - - expect(getAssetsSpy).not.toHaveBeenCalled(); - getAssetsSpy.mockRestore(); - }); - }); - - it('skips init-time selectedAccountGroupChange so :initialized owns first start', async () => { - await withController(async ({ controller, messenger }) => { - const getAssetsSpy = jest.spyOn(controller, 'getAssets'); - - ( - messenger as unknown as { - publish: (topic: string, payload?: unknown) => void; - } - ).publish('ClientController:stateChange', { isUiOpen: true }); - messenger.publish('KeyringController:unlock'); - await flushPromises(); - - // ATC always publishes this on init, even when the group did not change. + it('handles account group change', async () => { + await withController(async ({ messenger }) => { (messenger.publish as CallableFunction)( 'AccountTreeController:selectedAccountGroupChange', - 'entropy:mock-keyring-id-1/0', - '', - ); - await flushPromises(); - - expect(getAssetsSpy).not.toHaveBeenCalled(); - - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); - await flushPromises(); - - expect(getAssetsSpy).toHaveBeenCalled(); - getAssetsSpy.mockRestore(); - }); - }); - - it('skips selectedAccountGroupChange when group id did not change', async () => { - await withController(async ({ controller, messenger }) => { - const getAssetsSpy = jest - .spyOn(controller, 'getAssets') - .mockResolvedValue({}); - - ( - messenger as unknown as { - publish: (topic: string, payload?: unknown) => void; - } - ).publish('ClientController:stateChange', { isUiOpen: true }); - messenger.publish('KeyringController:unlock'); - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, + undefined, ); - await flushPromises(); - - getAssetsSpy.mockClear(); - (messenger.publish as CallableFunction)( - 'AccountTreeController:selectedAccountGroupChange', - 'entropy:mock-keyring-id-1/0', - 'entropy:mock-keyring-id-1/0', - ); - await flushPromises(); + await new Promise(process.nextTick); - expect(getAssetsSpy).not.toHaveBeenCalled(); - getAssetsSpy.mockRestore(); + expect(true).toBe(true); }); }); }); - describe('account tree initialized', () => { - it('triggers start when the tree initializes after unlock with empty accounts', async () => { + describe('account tree state change', () => { + it('triggers start when tree initializes after unlock with empty accounts', async () => { const getAccountsMock = jest.fn().mockReturnValue([]); const messenger: RootMessenger = new Messenger({ @@ -3568,14 +3439,6 @@ describe('AssetsController', () => { 'AccountTreeController:getAccountsFromSelectedAccountGroup', getAccountsMock, ); - ( - messenger as { - registerActionHandler: (a: string, h: () => unknown) => void; - } - ).registerActionHandler( - 'AccountsController:getSelectedAccount', - () => undefined, - ); messenger.registerActionHandler( 'NetworkEnablementController:getState', () => ({ @@ -3630,7 +3493,7 @@ describe('AssetsController', () => { expect(getAssetsSpy).not.toHaveBeenCalled(); - // Intermediate tree mutations during init must not start tracking. + // Step 2: AccountTreeController.init() completes — accounts now available getAccountsMock.mockReturnValue([createMockInternalAccount()]); (messenger.publish as CallableFunction)( 'AccountTreeController:stateChange', @@ -3639,15 +3502,6 @@ describe('AssetsController', () => { ); await new Promise((resolve) => setTimeout(resolve, 100)); - expect(getAssetsSpy).not.toHaveBeenCalled(); - - // Step 2: AccountTreeController.init() completes — tree is ready - (messenger.publish as CallableFunction)( - 'AccountTreeController:initialized', - {}, - ); - await new Promise((resolve) => setTimeout(resolve, 100)); - expect(getAssetsSpy).toHaveBeenCalledTimes(2); expect(getAssetsSpy).toHaveBeenNthCalledWith( 1, @@ -3663,8 +3517,6 @@ describe('AssetsController', () => { assetsForPriceUpdate: expect.arrayContaining(['eip155:1/slip44:60']), }), ); - - controller.destroy(); }); }); }); diff --git a/packages/assets-controller/src/AssetsController.ts b/packages/assets-controller/src/AssetsController.ts index 65a2cdba703..ed4cd54dc99 100644 --- a/packages/assets-controller/src/AssetsController.ts +++ b/packages/assets-controller/src/AssetsController.ts @@ -1,9 +1,7 @@ import type { AccountTreeControllerGetAccountsFromSelectedAccountGroupAction, - AccountTreeControllerInitializedEvent, AccountTreeControllerSelectedAccountGroupChangeEvent, AccountTreeControllerStateChangeEvent, - AccountTreeControllerUninitializedEvent, } from '@metamask/account-tree-controller'; import type { AccountsControllerGetSelectedAccountAction } from '@metamask/accounts-controller'; import { BaseController } from '@metamask/base-controller'; @@ -347,8 +345,6 @@ type AllowedEvents = // AssetsController | AccountTreeControllerSelectedAccountGroupChangeEvent | AccountTreeControllerStateChangeEvent - | AccountTreeControllerInitializedEvent - | AccountTreeControllerUninitializedEvent | ClientControllerStateChangeEvent | KeyringControllerLockEvent | KeyringControllerUnlockEvent @@ -749,12 +745,6 @@ export class AssetsController extends BaseController< /** Whether the keyring is unlocked. Combined with #uiOpen for #updateActive. */ #keyringUnlocked = false; - /** - * Whether `AccountTreeController` has finished `init()`. Unlock / UI-open - * alone must not start fetches — the tree can still be mid-build. - */ - #accountTreeInitialized = false; - readonly #controllerMutex = new Mutex(); /** Serializes account-switch fetch + subscribe to prevent overlapping races. */ @@ -1113,17 +1103,18 @@ export class AssetsController extends BaseController< // Subscribe to account group changes (when user switches between account groups like Account 1 -> Account 2) this.messenger.subscribe( 'AccountTreeController:selectedAccountGroupChange', - (groupId, previousGroupId) => { - this.#handleAccountGroupChanged(groupId, previousGroupId).catch( - console.error, - ); + () => { + this.#handleAccountGroupChanged().catch(console.error); }, ); - // Catch post-init tree mutations that change the selected account set - // (e.g. a snap account added to the current group) without changing - // the selected group id. Intermediate `:stateChange` events during - // `AccountTreeController.init()` are ignored until `:initialized`. + // Catch the initial tree build. On returning users, + // `selectedAccountGroupChange` does NOT fire when the persisted group + // is unchanged, and `accountTreeChange` doesn't fire either (init() + // rebuilds from persisted accounts without publishing it). + // The base-controller `:stateChange` event is guaranteed to fire + // when init() calls this.update(). #start() is idempotent so + // repeated fires are safe. this.messenger.subscribe('AccountTreeController:stateChange', () => { this.#handleAccountTreeStateChange(); }); @@ -1205,17 +1196,6 @@ export class AssetsController extends BaseController< this.#onTransactionConfirmed(transactionMeta); }, ); - // Start tracking only after the account tree is fully built. Unlock can - // happen before `AccountTreeController.init()`, and `:stateChange` fires - // for intermediate mutations during that build. - this.messenger.subscribe('AccountTreeController:initialized', () => { - this.#accountTreeInitialized = true; - this.#updateActive(); - }); - this.messenger.subscribe('AccountTreeController:uninitialized', () => { - this.#accountTreeInitialized = false; - this.#updateActive(); - }); } #onUnapprovedTransactionAdded(transactionMeta: TransactionMeta): void { @@ -1275,13 +1255,11 @@ export class AssetsController extends BaseController< } /** - * Start or stop asset tracking based on client (UI) open state, keyring - * unlock state, and account-tree readiness. Only runs when the UI is open, - * the keyring is unlocked, and the account tree has finished `init()`. + * Start or stop asset tracking based on client (UI) open state and keyring + * unlock state. Only runs when both UI is open and keyring is unlocked. */ #updateActive(): void { - const shouldRun = - this.#uiOpen && this.#keyringUnlocked && this.#accountTreeInitialized; + const shouldRun = this.#uiOpen && this.#keyringUnlocked; if (shouldRun) { this.#start(); } else { @@ -1290,54 +1268,56 @@ export class AssetsController extends BaseController< } /** - * Handle AccountTreeController state changes after the tree is initialized. - * Re-subscribe only when the set of selected accounts has actually changed - * (e.g. a snap account was added after initial startup). Intermediate - * `:stateChange` events during `init()` are ignored — startup is driven by - * `:initialized` instead, so we do not fetch on every tree mutation. + * Handle AccountTreeController state changes. + * If already running, re-subscribe only when the set of selected accounts + * has actually changed (e.g. a snap account was added after initial startup). + * This guards against the many tree mutations that don't affect which + * accounts are selected — without this check every tree update would + * trigger a redundant full re-subscribe + forceUpdate fetch. + * If not running yet, delegate to #start() for the normal start flow. */ #handleAccountTreeStateChange(): void { - const shouldRun = - this.#uiOpen && - this.#keyringUnlocked && - this.#accountTreeInitialized && - this.#activeSubscriptions.size > 0; + const shouldRun = this.#uiOpen && this.#keyringUnlocked; if (!shouldRun) { return; } - const accounts = this.#getSelectedAccounts(); - const currentIds = new Set(accounts.map((a) => a.id)); + if (this.#activeSubscriptions.size > 0) { + const accounts = this.#getSelectedAccounts(); + const currentIds = new Set(accounts.map((a) => a.id)); - const accountsChanged = - currentIds.size !== this.#lastKnownAccountIds.size || - [...currentIds].some((id) => !this.#lastKnownAccountIds.has(id)); + const accountsChanged = + currentIds.size !== this.#lastKnownAccountIds.size || + [...currentIds].some((id) => !this.#lastKnownAccountIds.has(id)); - if (!accountsChanged) { - return; - } + if (!accountsChanged) { + return; + } - const hasOverlap = [...currentIds].some((id) => - this.#lastKnownAccountIds.has(id), - ); - if (!hasOverlap && this.#lastKnownAccountIds.size > 0) { - return; - } + const hasOverlap = [...currentIds].some((id) => + this.#lastKnownAccountIds.has(id), + ); + if (!hasOverlap && this.#lastKnownAccountIds.size > 0) { + return; + } - log('Account tree changed with new accounts, re-subscribing', { - previousCount: this.#lastKnownAccountIds.size, - currentCount: currentIds.size, - }); + log('Account tree changed with new accounts, re-subscribing', { + previousCount: this.#lastKnownAccountIds.size, + currentCount: currentIds.size, + }); - const newAccounts = accounts.filter( - (account) => !this.#lastKnownAccountIds.has(account.id), - ); + const newAccounts = accounts.filter( + (account) => !this.#lastKnownAccountIds.has(account.id), + ); - this.#lastKnownAccountIds = currentIds; - this.#ensureNativeBalancesDefaultZero(); - this.#ensureDefaultTrackedAssetsSeeded(); - this.#runAccountTreeRefresh(accounts, newAccounts).catch((error) => { - log('Failed to refresh assets after tree change', error); - }); + this.#lastKnownAccountIds = currentIds; + this.#ensureNativeBalancesDefaultZero(); + this.#ensureDefaultTrackedAssetsSeeded(); + this.#runAccountTreeRefresh(accounts, newAccounts).catch((error) => { + log('Failed to refresh assets after tree change', error); + }); + } else { + this.#start(); + } } async #runAccountTreeRefresh( @@ -1350,7 +1330,7 @@ export class AssetsController extends BaseController< chainIds: [...this.#enabledChains], forceUpdate: true, }); - this.#subscribeAssets({ skipInitialFetch: true }); + this.#subscribeAssets(); if (newAccounts.length > 0) { await this.getAssets(newAccounts, { chainIds: [...this.#enabledChains], @@ -1360,7 +1340,7 @@ export class AssetsController extends BaseController< this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); } catch (error) { log('Failed to fetch assets after tree change', error); - this.#subscribeAssets({ skipInitialFetch: true }); + this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); } finally { releaseLock(); @@ -1383,14 +1363,13 @@ export class AssetsController extends BaseController< // and default tracked assets that were never returned by balance APIs. this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); - // Balances were just force-fetched — skip AccountsApi's subscribe-time poll. - this.#subscribeAssets({ skipInitialFetch: true }); + this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); } catch (error) { log('Failed to fetch assets on startup', error); this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); - this.#subscribeAssets({ skipInitialFetch: true }); + this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); } finally { releaseLock(); @@ -3178,12 +3157,8 @@ export class AssetsController extends BaseController< /** * Subscribe to asset updates for all selected accounts. - * - * @param options - Subscription options. - * @param options.skipInitialFetch - When true, AccountsApi skips its - * one-shot subscribe poll (use after a force `getAssets` for the same scope). */ - #subscribeAssets(options?: { skipInitialFetch?: boolean }): void { + #subscribeAssets(): void { const accounts = this.#getSelectedAccounts(); const enabledChains = [...this.#enabledChains]; if (accounts.length === 0 || enabledChains.length === 0) { @@ -3191,7 +3166,7 @@ export class AssetsController extends BaseController< } // Subscribe to balance updates (batched by data source) - this.#subscribeAssetsBalance(accounts, enabledChains, options); + this.#subscribeAssetsBalance(accounts, enabledChains); // Subscribe to staked balance updates (separate from regular balance chain-claiming) this.#subscribeStakedBalance(accounts, enabledChains); @@ -3213,13 +3188,10 @@ export class AssetsController extends BaseController< * * @param accounts - Accounts to subscribe balance updates for. * @param chainIds - Chain IDs to subscribe for. - * @param options - Subscription options. - * @param options.skipInitialFetch - Forwarded to AccountsApi subscribe. */ #subscribeAssetsBalance( accounts: InternalAccount[], chainIds: ChainId[], - options?: { skipInitialFetch?: boolean }, ): void { const chainToAccounts = this.#buildChainToAccountsMap( accounts, @@ -3266,12 +3238,7 @@ export class AssetsController extends BaseController< return true; }); if (accountsForSource.length > 0) { - this.#subscribeDataSource(source, accountsForSource, assignedChains, { - ...(options?.skipInitialFetch && - source === this.#accountsApiDataSource - ? { skipInitialFetch: true } - : {}), - }); + this.#subscribeDataSource(source, accountsForSource, assignedChains); } } @@ -3421,17 +3388,12 @@ export class AssetsController extends BaseController< * @param options - Optional subscription overrides. * @param options.subscriptionKey - Custom subscription key (default: `ds:`). * @param options.customAssetsOnly - When true, only poll customAssets for these chains. - * @param options.skipInitialFetch - When true, skip the data source's subscribe-time fetch. */ #subscribeDataSource( source: AbstractDataSource, accounts: InternalAccount[], chains: ChainId[], - options: { - subscriptionKey?: string; - customAssetsOnly?: boolean; - skipInitialFetch?: boolean; - } = {}, + options: { subscriptionKey?: string; customAssetsOnly?: boolean } = {}, ): void { const sourceId = source.getName(); const subscriptionKey = options.subscriptionKey ?? `ds:${sourceId}`; @@ -3445,7 +3407,6 @@ export class AssetsController extends BaseController< accountCount: accounts.length, chainCount: chains.length, customAssetsOnly: options.customAssetsOnly === true, - skipInitialFetch: options.skipInitialFetch === true, }); const subscribeReq: SubscriptionRequest = { @@ -3462,7 +3423,6 @@ export class AssetsController extends BaseController< onAssetsUpdate: (response, request) => this.handleAssetsUpdate(response, sourceId, request), getAssetsState: () => this.state, - ...(options.skipInitialFetch === true ? { skipInitialFetch: true } : {}), }; source.subscribe(subscribeReq).catch((error) => { @@ -3608,33 +3568,12 @@ export class AssetsController extends BaseController< // EVENT HANDLERS // ============================================================================ - async #handleAccountGroupChanged( - groupId: string, - previousGroupId: string = '', - ): Promise { - // The selected account group can be empty during onboarding or wallet reset. - if (!groupId) { - return; - } - - // First start is owned by `AccountTreeController:initialized` / `#start`. - // ATC also re-publishes `selectedAccountGroupChange` on every `init()` - // (including when the group did not change) so late subscribers can catch - // up — ignore those until we are already tracking. - if (this.#activeSubscriptions.size === 0) { - return; - } - if (groupId === previousGroupId) { - return; - } - + async #handleAccountGroupChanged(): Promise { const accounts = this.#getSelectedAccounts(); log('Account group changed', { accountCount: accounts.length, accountIds: accounts.map((a) => a.id), - groupId, - previousGroupId, }); this.#lastKnownAccountIds = new Set(accounts.map((a) => a.id)); @@ -3651,8 +3590,7 @@ export class AssetsController extends BaseController< this.#ensureNativeBalancesDefaultZero(); this.#ensureDefaultTrackedAssetsSeeded(); // Subscribe after seed so the price poll sees natives / defaults. - // Balances were just force-fetched — skip AccountsApi's subscribe-time poll. - this.#subscribeAssets({ skipInitialFetch: true }); + this.#subscribeAssets(); this.#fetchMissingPricesWithoutCache(accounts, [...this.#enabledChains]); } finally { releaseLock(); diff --git a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts index 537ca99dcc2..9dab7709c34 100644 --- a/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts +++ b/packages/assets-controller/src/__fixtures__/MockAssetControllerMessenger.ts @@ -67,8 +67,6 @@ export function createMockAssetControllerMessenger(): { // AssetsController 'AccountTreeController:selectedAccountGroupChange', 'AccountTreeController:stateChange', - 'AccountTreeController:initialized', - 'AccountTreeController:uninitialized', 'ClientController:stateChange', 'KeyringController:lock', 'KeyringController:unlock', diff --git a/packages/assets-controller/src/data-sources/AbstractDataSource.ts b/packages/assets-controller/src/data-sources/AbstractDataSource.ts index 6023b18dfb2..a669700e101 100644 --- a/packages/assets-controller/src/data-sources/AbstractDataSource.ts +++ b/packages/assets-controller/src/data-sources/AbstractDataSource.ts @@ -26,12 +26,6 @@ export type SubscriptionRequest = { * Provided by the controller when subscribing. */ getAssetsState?: () => AssetsControllerStateInternal; - /** - * When true, skip the one-shot fetch that normally runs when a subscription - * is created. Used after the controller has already force-fetched balances - * for the same accounts/chains so we do not immediately hit the API again. - */ - skipInitialFetch?: boolean; }; /** diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts index 1877db8f8d1..68bf5896a07 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.test.ts @@ -525,7 +525,7 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('fetch bypasses TanStack cache when forceUpdate is true', async () => { + it('uses a short-lived TanStack cache window when forceUpdate is true', async () => { const { controller, apiClient } = await setupController(); await controller.fetch(createDataRequest({ forceUpdate: true })); @@ -533,7 +533,7 @@ describe('AccountsApiDataSource', () => { expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( [`eip155:1:${MOCK_ADDRESS}`], undefined, - { staleTime: 0, gcTime: 0 }, + { staleTime: 100, gcTime: 100 }, ); controller.destroy(); @@ -947,34 +947,6 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('middleware skips Accounts API when balance is not requested', async () => { - const { controller, apiClient } = await setupController({ - balances: [ - createMockBalanceItem( - `eip155:1:${MOCK_ADDRESS}`, - 'eip155:1/slip44:60', - '1', - ), - ], - }); - - apiClient.accounts.fetchV5MultiAccountBalances.mockClear(); - - const next = jest.fn().mockResolvedValue(undefined); - const context = createMiddlewareContext({ - request: createDataRequest({ dataTypes: ['price'] }), - }); - - await controller.assetsMiddleware(context, next); - - expect( - apiClient.accounts.fetchV5MultiAccountBalances, - ).not.toHaveBeenCalled(); - expect(next).toHaveBeenCalledWith(context); - - controller.destroy(); - }); - it('middleware removes handled chains from next request', async () => { const { controller } = await setupController({ supportedChains: [1] }); @@ -1011,48 +983,6 @@ describe('AccountsApiDataSource', () => { controller.destroy(); }); - it('subscribe polling fetch always bypasses the TanStack cache', async () => { - const { controller, apiClient, assetsUpdateHandler } = - await setupController(); - - await controller.subscribe({ - subscriptionId: 'sub-1', - request: createDataRequest(), - isUpdate: false, - onAssetsUpdate: assetsUpdateHandler, - }); - - expect(apiClient.accounts.fetchV5MultiAccountBalances).toHaveBeenCalledWith( - [`eip155:1:${MOCK_ADDRESS}`], - undefined, - { staleTime: 0, gcTime: 0 }, - ); - - controller.destroy(); - }); - - it('subscribe skips initial fetch when skipInitialFetch is true', async () => { - const { controller, assetsUpdateHandler, apiClient } = - await setupController(); - - apiClient.accounts.fetchV5MultiAccountBalances.mockClear(); - - await controller.subscribe({ - subscriptionId: 'sub-1', - request: createDataRequest(), - isUpdate: false, - onAssetsUpdate: assetsUpdateHandler, - skipInitialFetch: true, - }); - - expect(assetsUpdateHandler).not.toHaveBeenCalled(); - expect( - apiClient.accounts.fetchV5MultiAccountBalances, - ).not.toHaveBeenCalled(); - - controller.destroy(); - }); - it('subscribe does nothing when no chains', async () => { const { controller, assetsUpdateHandler } = await setupController(); diff --git a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts index 6612d079aaa..3de2633e958 100644 --- a/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts +++ b/packages/assets-controller/src/data-sources/AccountsApiDataSource.ts @@ -428,7 +428,7 @@ export class AccountsApiDataSource extends AbstractDataSource< } const fetchOptions = request.forceUpdate - ? { staleTime: 0, gcTime: 0 } + ? { staleTime: 100, gcTime: 100 } : undefined; // Feature-flagged: v6 endpoint with a fallback to legacy v5. The flag is @@ -721,11 +721,6 @@ export class AccountsApiDataSource extends AbstractDataSource< return async (context, next) => { const { request } = context; - // Price/metadata-only requests must not hit the Accounts API. - if (!request.dataTypes.includes('balance')) { - return next(context); - } - // If no chains requested, skip to next middleware if (request.chainIds.length === 0) { return next(context); @@ -795,8 +790,7 @@ export class AccountsApiDataSource extends AbstractDataSource< // ============================================================================ async subscribe(subscriptionRequest: SubscriptionRequest): Promise { - const { request, subscriptionId, isUpdate, skipInitialFetch } = - subscriptionRequest; + const { request, subscriptionId, isUpdate } = subscriptionRequest; // Store state accessor for filtering when tokenDetectionEnabled is false if (subscriptionRequest.getAssetsState) { @@ -857,13 +851,10 @@ export class AccountsApiDataSource extends AbstractDataSource< return; } - // Use stored request (which gets updated on account changes). - // forceUpdate so we don't get a stale response from the cache - // (STALE_TIMES.BALANCES is 60s, longer than our 30s poll interval). + // Use stored request (which gets updated on account changes) const fetchResponse = await this.fetch({ ...subscription.request, chainIds: subscription.chains, - forceUpdate: true, }); // Report update to AssetsController via callback @@ -888,12 +879,8 @@ export class AccountsApiDataSource extends AbstractDataSource< onAssetsUpdate: subscriptionRequest.onAssetsUpdate, }); - // Interval above still polls on the normal cadence. This only skips the - // one-shot fetch at subscribe time when the controller already ran a - // force getAssets for the same scope (startup / group refresh). - if (!skipInitialFetch) { - await pollFn(); - } + // Initial fetch + await pollFn(); } // ============================================================================ diff --git a/packages/assets-controller/src/defaults.ts b/packages/assets-controller/src/defaults.ts index 613819c7002..6c587cddf85 100644 --- a/packages/assets-controller/src/defaults.ts +++ b/packages/assets-controller/src/defaults.ts @@ -32,16 +32,20 @@ const MUSD_METADATA: FungibleAssetMetadata = { }; /** - * Arc's native USDC also exists as its own ERC20 (`0x3600...`); Accounts API - * resolves that identity without ever seeding native `assetsInfo` metadata. + * Harcoded metadata for USDC on Arc (5042/0x13b2). + * USDC exists as both native (18 decimals) and ERC20 (6 decimals). + * We choose to force-hide the native version to avoid double listing using + * `CHAIN_IDS_WITH_NO_NATIVE_TOKEN`. + * In the meantime, the code below force-shows USDC the ERC20 token. */ -const ARC_NATIVE_ASSET_ID = 'eip155:5042/slip44:5042'; +const USDC_ON_ARC_ASSET_ID = + 'eip155:5042/erc20:0x3600000000000000000000000000000000000000'; -const ARC_NATIVE_METADATA: FungibleAssetMetadata = { - type: 'native', +const USDC_ON_ARC_METADATA: FungibleAssetMetadata = { + type: 'erc20', symbol: 'USDC', name: 'USDC', - decimals: 18, + decimals: 6, }; /** @@ -72,7 +76,7 @@ export const DEFAULT_TRACKED_ASSETS_BY_CHAIN: ReadonlyMap< ['eip155:1' as ChainId, [musdAssetId('eip155:1' as ChainId)]], ['eip155:59144' as ChainId, [musdAssetId('eip155:59144' as ChainId)]], ['eip155:143' as ChainId, [musdAssetId('eip155:143' as ChainId)]], - ['eip155:5042' as ChainId, [ARC_NATIVE_ASSET_ID]], + ['eip155:5042' as ChainId, [USDC_ON_ARC_ASSET_ID]], ]); /** @@ -93,7 +97,7 @@ export const DEFAULT_ASSET_METADATA: ReadonlyMap = [musdAssetId('eip155:1' as ChainId), MUSD_METADATA], [musdAssetId('eip155:59144' as ChainId), MUSD_METADATA], [musdAssetId('eip155:143' as ChainId), MUSD_METADATA], - [ARC_NATIVE_ASSET_ID, ARC_NATIVE_METADATA], + [USDC_ON_ARC_ASSET_ID, USDC_ON_ARC_METADATA], ]); /** diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 30a91c87293..9df82a17e9b 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -9,17 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/network-enablement-controller` from `^6.0.3` to `^6.0.4` ([#9923](https://github.com/MetaMask/core/pull/9923)) - -## [111.1.1] - -### Changed - - Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) - Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.0` ([#9807](https://github.com/MetaMask/core/pull/9807)) -- Bump `@metamask/account-tree-controller` from `^7.6.1` to `^8.0.0` ([#9886](https://github.com/MetaMask/core/pull/9886)) -- Bump `@metamask/core-backend` from `^8.1.1` to `^8.1.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) -- Bump `@metamask/multichain-account-service` from `^13.0.1` to `^13.0.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) ### Fixed @@ -3429,8 +3420,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@111.1.1...HEAD -[111.1.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@111.1.0...@metamask/assets-controllers@111.1.1 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@111.1.0...HEAD [111.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@111.0.0...@metamask/assets-controllers@111.1.0 [111.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@110.1.1...@metamask/assets-controllers@111.0.0 [110.1.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@110.1.0...@metamask/assets-controllers@110.1.1 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 9fefbbb603e..1173510c451 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "111.1.1", + "version": "111.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", @@ -62,21 +62,21 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/abi-utils": "^2.0.3", - "@metamask/account-tree-controller": "^8.0.0", + "@metamask/account-tree-controller": "^7.6.1", "@metamask/accounts-controller": "^39.1.0", "@metamask/approval-controller": "^9.0.2", "@metamask/base-controller": "^9.1.0", "@metamask/contract-metadata": "^2.4.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/core-backend": "^8.1.2", + "@metamask/core-backend": "^8.1.1", "@metamask/eth-query": "^4.0.0", "@metamask/keyring-api": "^24.0.0", "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", - "@metamask/multichain-account-service": "^13.0.2", + "@metamask/multichain-account-service": "^13.0.1", "@metamask/network-controller": "^35.0.1", - "@metamask/network-enablement-controller": "^6.0.4", + "@metamask/network-enablement-controller": "^6.0.3", "@metamask/permission-controller": "^13.1.1", "@metamask/phishing-controller": "^17.3.1", "@metamask/polling-controller": "^16.0.9", diff --git a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts index bd00f5dfed6..afda203d7ff 100644 --- a/packages/assets-controllers/src/token-prices-service/codefi-v2.ts +++ b/packages/assets-controllers/src/token-prices-service/codefi-v2.ts @@ -243,11 +243,11 @@ const chainIdToNativeTokenAddress: Record = { export const getNativeTokenAddress = (chainId: Hex): Hex => chainIdToNativeTokenAddress[chainId] ?? ZERO_ADDRESS; -// Price API v3/spot-prices chains only — verify support before adding: // Source: https://github.com/consensys-vertical-apps/va-mmcx-price-api/blob/main/src/constants/slip44.ts -// https://price.api.cx.metamask.io/v2/supportedNetworks -// https://price.api.cx.metamask.io/v3/spot-prices?assetIds=&vsCurrency=usd -// Include chain name + native symbol. Keep sorted by chain ID. +// This list is ONLY for chains that are supported by the Price API v3/spot-prices endpoint. +// Please check that endpoint returns a price for the given assetId before including it in this list. +// Please include a comment with the name of the chain and the native symbol. +// Please keep the list sorted by chain ID. export const SPOT_PRICES_SUPPORT_INFO = { '0x1': 'eip155:1/slip44:60', // Ethereum Mainnet - Native symbol: ETH '0xa': 'eip155:10/slip44:60', // OP Mainnet - Native symbol: ETH diff --git a/packages/base-data-service/CHANGELOG.md b/packages/base-data-service/CHANGELOG.md index 217c63d47c1..87aa5230ac5 100644 --- a/packages/base-data-service/CHANGELOG.md +++ b/packages/base-data-service/CHANGELOG.md @@ -33,17 +33,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `handleAll` - `handleWhen` - Export types `DataServiceActions` and `DataServiceEvents` ([#9475](https://github.com/MetaMask/core/pull/9475)) -- Add `responseStruct` option to `fetchQuery` and `fetchInfiniteQuery` for validating query responses using Superstruct ([#9540](https://github.com/MetaMask/core/pull/9540)) - - When provided, the struct is used to validate the response and for inferring the return type of the query ### Changed - **BREAKING:** Remove `TPageData` type parameter from `invalidateQueries` method ([#9526](https://github.com/MetaMask/core/pull/9526)) - This is technically a breaking change, but this was not used in any of our codebases -- **BREAKING:** Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - - The option types accepted by `fetchQuery`, `fetchInfiniteQuery`, and `invalidateQueries` now follow the query-core v5 API. Subclasses need to rename `cacheTime` to `gcTime`. - - `fetchInfiniteQuery` now requires `initialPageParam` and `getNextPageParam`, matching query-core's options for infinite queries. This also lets `TPageParam` be inferred instead of spelled out in the type parameters. -- **BREAKING:** `fetchQuery` and `fetchInfiniteQuery` now take one more type parameter: `TDataStruct` is the third parameter and the others have been shifted up ([#9540](https://github.com/MetaMask/core/pull/9540)) - Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) - Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) - Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) diff --git a/packages/base-data-service/package.json b/packages/base-data-service/package.json index d40129f3adf..32a4ca2d3a7 100644 --- a/packages/base-data-service/package.json +++ b/packages/base-data-service/package.json @@ -58,9 +58,8 @@ "dependencies": { "@metamask/messenger": "^2.0.0", "@metamask/storage-service": "^1.0.2", - "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16", + "@tanstack/query-core": "^4.43.0", "cockatiel": "^3.1.2", "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" diff --git a/packages/base-data-service/src/BaseDataService.test.ts b/packages/base-data-service/src/BaseDataService.test.ts index f4e965de5c3..df6ed594d36 100644 --- a/packages/base-data-service/src/BaseDataService.test.ts +++ b/packages/base-data-service/src/BaseDataService.test.ts @@ -1,5 +1,5 @@ import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; -import { hashKey } from '@tanstack/query-core'; +import { hashQueryKey } from '@tanstack/query-core'; import { BrokenCircuitError } from 'cockatiel'; import { cleanAll } from 'nock'; @@ -131,7 +131,7 @@ describe('BaseDataService', () => { const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; - const hash = hashKey(queryKey); + const hash = hashQueryKey(queryKey); expect(publishSpy).toHaveBeenNthCalledWith( 6, @@ -186,7 +186,7 @@ describe('BaseDataService', () => { const queryKey = ['ExampleDataService:getAssets', MOCK_ASSETS]; - const hash = hashKey(queryKey); + const hash = hashQueryKey(queryKey); expect(publishSpy).toHaveBeenNthCalledWith( 8, @@ -225,33 +225,6 @@ describe('BaseDataService', () => { expect(publishSpy).toHaveBeenCalledTimes(8); }); - describe('validation', () => { - beforeAll(() => { - jest.useRealTimers(); - }); - - afterAll(() => { - jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate'] }); - }); - - beforeEach(() => { - cleanAll(); - }); - - it('throws when fetchQuery response fails struct validation', async () => { - const messenger = new Messenger({ namespace: serviceName }); - const service = new ExampleDataService(messenger); - - mockAssets({ status: 200, body: { foo: 'bar' } }); - - await expect(service.getAssets(MOCK_ASSETS)).rejects.toThrow( - 'Query function for "ExampleDataService:getAssets" returned an unexpected response: Expected an array value, but received: [object Object].', - ); - - service.destroy(); - }); - }); - describe('service policy', () => { beforeAll(() => { jest.useRealTimers(); @@ -360,7 +333,6 @@ describe('BaseDataService', () => { state: { queries: [ { - dehydratedAt: expect.any(Number), queryHash: '["ExampleDataService:getAssets",["eip155:1/slip44:60","bip122:000000000019d6689c085ae165831e93/slip44:0","eip155:1/erc20:0x6b175474e89094c44da98b954eedeac495271d0f"]]', queryKey: [ diff --git a/packages/base-data-service/src/BaseDataService.ts b/packages/base-data-service/src/BaseDataService.ts index cc6fef841aa..6c9b1146e19 100644 --- a/packages/base-data-service/src/BaseDataService.ts +++ b/packages/base-data-service/src/BaseDataService.ts @@ -8,22 +8,19 @@ import type { StorageServiceRemoveItemAction, StorageServiceSetItemAction, } from '@metamask/storage-service'; -import { Struct } from '@metamask/superstruct'; import { Duration, inMilliseconds } from '@metamask/utils'; import type { Json } from '@metamask/utils'; import { - DefaultError, DefaultOptions, DehydratedState, + FetchInfiniteQueryOptions, FetchQueryOptions, InfiniteData, - InfiniteQueryPageParamsOptions, InvalidateOptions, InvalidateQueryFilters, OmitKeyof, QueryClient, QueryClientConfig, - QueryFunction, WithRequired, dehydrate, hydrate, @@ -36,27 +33,10 @@ import { CreateServicePolicyOptions, ServicePolicy, } from './createServicePolicy.js'; -import { processQueryResponse } from './utils.js'; // Data service queries use the following format: ['ServiceActionName', ...params] export type QueryKey = [string, ...Json[]]; -/** - * The supertype of all messengers, scoped to a namespace. - * - * @template Namespace - The namespace for the messenger's own actions and - * events. - */ -export type BaseMessenger = Messenger< - Namespace, - ActionConstraint, - EventConstraint, - // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, - // it's the one totally safe place to use it. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - any ->; - export type DataServiceGranularCacheUpdatedPayload = | { type: 'added' | 'updated'; state: DehydratedState } | { @@ -73,10 +53,10 @@ type CacheUpdatedType = DataServiceCacheUpdatedPayload['type']; export type DataServiceInvalidateQueriesAction = { type: `${ServiceName}:invalidateQueries`; - handler: BaseDataService< - ServiceName, - BaseMessenger - >['invalidateQueries']; + handler: ( + filters?: InvalidateQueryFilters, + options?: InvalidateOptions, + ) => Promise; }; export type DataServiceActions = @@ -138,7 +118,15 @@ type PersistedCache = { export class BaseDataService< ServiceName extends string, - ServiceMessenger extends BaseMessenger, + ServiceMessenger extends Messenger< + ServiceName, + ActionConstraint, + EventConstraint, + // Use `any` to allow any parent to be set. `any` is harmless in a type constraint anyway, + // it's the one totally safe place to use it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + any + >, > { public readonly name: ServiceName; @@ -252,38 +240,26 @@ export class BaseDataService< * * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. - * @param options.responseStruct - An optional struct for validating the response of the query function. * @returns The query results. */ protected async fetchQuery< TQueryFnData extends Json, - TError = DefaultError, - TDataStruct extends Struct | undefined = undefined, - TData = TDataStruct extends Struct - ? StructType - : TQueryFnData, + TError = unknown, + TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, - >({ - responseStruct, - ...options - }: WithRequired< - OmitKeyof< - FetchQueryOptions, - 'retry' | 'retryDelay' | 'queryFn' + >( + options: WithRequired< + OmitKeyof< + FetchQueryOptions, + 'retry' | 'retryDelay' + >, + 'queryKey' | 'queryFn' >, - 'queryKey' - > & { - queryFn: QueryFunction; - responseStruct?: TDataStruct; - }): Promise { + ): Promise { return this.#queryClient.fetchQuery({ ...options, - queryFn: async (context) => { - const response = await this.#policy.execute(() => - options.queryFn(context), - ); - return processQueryResponse(options.queryKey, response, responseStruct); - }, + queryFn: (context) => + this.#policy.execute(() => options.queryFn(context)), }); } @@ -292,88 +268,59 @@ export class BaseDataService< * * @param options - The options defining the query. Keep in mind that `queryKey` and `queryFn` are required when using data services. * Additionally `retry` and `retryDelay` are not available, retries can be customized using the `servicePolicyOptions`. - * @param options.responseStruct - An optional struct for validating the response of the query function. * @param pageParam - An optional page parameter. * @returns The query result, exclusively the requested page is returned. */ protected async fetchInfiniteQuery< TQueryFnData extends Json, - TError = DefaultError, - TDataStruct extends Struct | undefined = undefined, - TData extends TQueryFnData = TDataStruct extends Struct - ? StructType - : TQueryFnData, + TError = unknown, + TData extends TQueryFnData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam extends Json = Json, >( - { - responseStruct, - ...options - }: WithRequired< + options: WithRequired< OmitKeyof< - FetchQueryOptions< - TQueryFnData, - TError, - InfiniteData, - TQueryKey, - TPageParam - >, - 'retry' | 'retryDelay' | 'queryFn' | 'initialPageParam' + FetchInfiniteQueryOptions, + 'retry' | 'retryDelay' >, - 'queryKey' - > & - InfiniteQueryPageParamsOptions & { - queryFn: QueryFunction; - responseStruct?: TDataStruct; - }, + 'queryKey' | 'queryFn' + >, pageParam?: TPageParam, ): Promise { const cache = this.#queryClient.getQueryCache(); - const query = cache.find< - TQueryFnData, - TError, - InfiniteData - >({ + const query = cache.find>({ queryKey: options.queryKey, }); - if (!query?.state.data || !pageParam) { + if (!query?.state.data || pageParam === undefined) { const result = await this.#queryClient.fetchInfiniteQuery({ ...options, - initialPageParam: pageParam ?? options.initialPageParam, - queryFn: async (context) => { - const response = await this.#policy.execute(async () => + queryFn: (context) => + this.#policy.execute(() => options.queryFn({ ...context, - pageParam: context.meta?.pageParam ?? context.pageParam, + pageParam: context.pageParam ?? pageParam, }), - ); - return processQueryResponse( - options.queryKey, - response, - responseStruct, - ); - }, + ), }); return result.pages[0]; } - const { pages, pageParams } = query.state.data; - const next = options.getNextPageParam( - pages[pages.length - 1], - pages, - pageParams[pageParams.length - 1], - pageParams, - ); + const { pages } = query.state.data; + const previous = options.getPreviousPageParam?.(pages[0], pages); - const direction = deepEqual(pageParam, next) ? 'forward' : 'backward'; + const direction = deepEqual(pageParam, previous) ? 'backward' : 'forward'; - const result = await query.fetch( - { ...query.options, meta: { pageParam } }, - { meta: { fetchMore: { direction } } }, - ); + const result = await query.fetch(undefined, { + meta: { + fetchMore: { + direction, + pageParam, + }, + }, + }); const pageIndex = result.pageParams.findIndex((param) => deepEqual(param, pageParam), @@ -390,7 +337,7 @@ export class BaseDataService< * @returns Nothing. */ async invalidateQueries( - filters?: InvalidateQueryFilters, + filters?: InvalidateQueryFilters, options?: InvalidateOptions, ): Promise { return this.#queryClient.invalidateQueries(filters, options); diff --git a/packages/base-data-service/src/utils.ts b/packages/base-data-service/src/utils.ts deleted file mode 100644 index 5a9329f5a10..00000000000 --- a/packages/base-data-service/src/utils.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Struct, validate } from '@metamask/superstruct'; - -import type { QueryKey } from './BaseDataService.js'; - -/** - * Process query responses, validating them using Superstruct if a struct is defined. - * - * @param queryKey - The query key. - * @param response - The query response - * @param struct - The struct defining the schema for the query response. - * @returns The query response, coerced by Superstruct if needed. - * @throws If the query response does not match the struct. - */ -export function processQueryResponse( - queryKey: QueryKey, - response: Response, - struct?: Struct, -): Response { - if (!struct) { - return response; - } - - const [error, result] = validate(response, struct); - - if (error) { - throw new Error( - `Query function for "${queryKey[0]}" returned an unexpected response: ${error.message}.`, - ); - } - - return result; -} diff --git a/packages/base-data-service/tests/ExampleDataService.ts b/packages/base-data-service/tests/ExampleDataService.ts index ec7bb8e73bf..d84e9edf5d1 100644 --- a/packages/base-data-service/tests/ExampleDataService.ts +++ b/packages/base-data-service/tests/ExampleDataService.ts @@ -1,12 +1,5 @@ import { Messenger } from '@metamask/messenger'; -import { object, number, string, array } from '@metamask/superstruct'; -import { - CaipAssetType, - CaipAssetTypeStruct, - Duration, - inMilliseconds, - Json, -} from '@metamask/utils'; +import { CaipAssetId, Duration, inMilliseconds, Json } from '@metamask/utils'; import { ConstantBackoff } from 'cockatiel'; import { @@ -35,20 +28,11 @@ export type ExampleMessenger = Messenger< >; export type GetAssetsResponse = { - assetId: CaipAssetType; + assetId: CaipAssetId; decimals: number; name: string; symbol: string; -}[]; - -const GetAssetsResponseStruct = array( - object({ - assetId: CaipAssetTypeStruct, - decimals: number(), - name: string(), - symbol: string(), - }), -); +}; export type GetActivityResponse = { data: Json[]; @@ -65,8 +49,7 @@ export type PageParam = | { before: string; } - | { after: string } - | null; + | { after: string }; const MESSENGER_EXPOSED_METHODS = ['getAssets', 'getActivity'] as const; @@ -118,8 +101,7 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, staleTime: inMilliseconds(1, Duration.Day), - gcTime: inMilliseconds(1, Duration.Day), - responseStruct: GetAssetsResponseStruct, + cacheTime: inMilliseconds(1, Duration.Day), }); } @@ -130,7 +112,6 @@ export class ExampleDataService extends BaseDataService< return this.fetchInfiniteQuery( { queryKey: [`${this.name}:getActivity`, address], - initialPageParam: null, queryFn: async ({ pageParam }) => { const caipAddress = `eip155:0:${address.toLowerCase()}`; const url = new URL( @@ -154,9 +135,11 @@ export class ExampleDataService extends BaseDataService< return response.json(); }, getPreviousPageParam: ({ pageInfo }) => - pageInfo.hasPreviousPage ? { before: pageInfo.startCursor } : null, + pageInfo.hasPreviousPage + ? { before: pageInfo.startCursor } + : undefined, getNextPageParam: ({ pageInfo }) => - pageInfo.hasNextPage ? { after: pageInfo.endCursor } : null, + pageInfo.hasNextPage ? { after: pageInfo.endCursor } : undefined, staleTime: inMilliseconds(5, Duration.Minute), }, page, diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index cc98f815243..abac3514736 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,17 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [79.3.0] - -### Added - -- Add Sentry quote-fetch and provider first-result performance traces ([#9899](https://github.com/MetaMask/core/pull/9899)) - -### Changed - -- Bump `@metamask/assets-controller` from `^13.1.2` to `^14.0.0` ([#9873](https://github.com/MetaMask/core/pull/9873), [#9886](https://github.com/MetaMask/core/pull/9886), [#9923](https://github.com/MetaMask/core/pull/9923)) -- Bump `@metamask/assets-controllers` from `^111.1.0` to `^111.1.1` ([#9886](https://github.com/MetaMask/core/pull/9886)) - ## [79.2.0] ### Added @@ -1923,8 +1912,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.3.0...HEAD -[79.3.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.2.0...@metamask/bridge-controller@79.3.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.2.0...HEAD [79.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.1.0...@metamask/bridge-controller@79.2.0 [79.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.0.1...@metamask/bridge-controller@79.1.0 [79.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@79.0.0...@metamask/bridge-controller@79.0.1 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 22fd30bcd7f..9929a51bbd2 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "79.3.0", + "version": "79.2.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -60,8 +60,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^39.1.0", - "@metamask/assets-controller": "^14.0.0", - "@metamask/assets-controllers": "^111.1.1", + "@metamask/assets-controller": "^13.1.2", + "@metamask/assets-controllers": "^111.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", "@metamask/gas-fee-controller": "^26.3.1", diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index 2c412553bf3..f0ebc848edd 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -1,6 +1,5 @@ import { BigNumber } from '@ethersproject/bignumber'; import * as ethersContractUtils from '@ethersproject/contracts'; -import type { TraceRequest } from '@metamask/controller-utils'; import { SolScope } from '@metamask/keyring-api'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -39,7 +38,6 @@ import { DEFAULT_BRIDGE_CONTROLLER_STATE, ETH_USDT_ADDRESS, } from './constants/bridge.js'; -import { TraceName } from './constants/traces.js'; import { ChainId, RequestStatus } from './types.js'; import type { BridgeControllerMessenger } from './types.js'; import * as balanceUtils from './utils/balance.js'; @@ -49,17 +47,12 @@ import { } from './utils/caip-formatters.js'; import * as featureFlagUtils from './utils/feature-flags.js'; import * as fetchUtils from './utils/fetch.js'; -import { AbortReason } from './utils/metrics/constants.js'; import { FeatureId } from './validators/feature-flags.js'; import { validateQuoteResponseV1 } from './validators/quote-response-v1.js'; import { QuoteStreamCompleteReason } from './validators/quote-stream-complete.js'; import { TokenFeatureType } from './validators/token-feature.js'; import type { TxData } from './validators/trade.js'; -jest.mock('uuid', () => ({ - v4: (): string => 'test-uuid-1234', -})); - type RootMessenger = Messenger< MockAnyNamespace, MessengerActions, @@ -111,16 +104,6 @@ const metricsContext = { token_security_type_destination: null, }; -const createTraceCallback = (traceRequests: TraceRequest[]): jest.Mock => - jest - .fn() - .mockImplementation( - async (request: TraceRequest, callback?: () => unknown) => { - traceRequests.push(request); - return await callback?.(); - }, - ); - const assetExchangeRates = { 'eip155:10/erc20:0x1f9840a85d5af5bf1d1762f925bdaddc4201f984': { exchangeRate: undefined, @@ -259,149 +242,6 @@ describe('BridgeController SSE', function () { jest.resetAllMocks(); }); - describe('quote tracing', () => { - const runTraceScenario = async ({ - response, - request = quoteRequest, - abort = false, - }: { - response: () => unknown; - request?: typeof quoteRequest; - abort?: boolean; - }): Promise => { - const traceRequests: TraceRequest[] = []; - - await withController( - { - options: { - traceFn: createTraceCallback(traceRequests), - }, - }, - async ({ controller, rootMessenger }) => { - mockFetchFn.mockImplementationOnce(async () => response()); - await rootMessenger.call( - 'BridgeController:updateBridgeQuoteRequestParams', - request, - metricsContext, - ); - jest.advanceTimersByTime(1000); - await advanceToNthTimerThenFlush(); - if (abort) { - controller.stopPollingForQuotes(AbortReason.NewQuoteRequest); - } - jest.advanceTimersByTime(11000); - await flushPromises(); - }, - ); - - return traceRequests; - }; - - const getTrace = ( - requests: TraceRequest[], - name: TraceName, - ): TraceRequest | undefined => - requests.find((request) => request.name === name); - - it('records cross-chain success and the first result from each provider', async () => { - const firstQuote = mockBridgeQuotesErc20Erc20V1[0]; - const secondProviderQuote = { - ...firstQuote, - quote: { - ...firstQuote.quote, - requestId: 'second-provider-request', - bridgeId: 'hop', - bridges: ['hop'], - protocols: ['hop'], - }, - }; - - const requests = await runTraceScenario({ - response: (): unknown => - mockSseEventSource([firstQuote, firstQuote, secondProviderQuote]), - }); - const providerTraces = requests.filter( - (request) => request.name === TraceName.QuoteProviderFirstResult, - ); - - expect( - getTrace(requests, TraceName.BridgeQuotesFetched)?.data, - ).toStrictEqual( - expect.objectContaining({ - request_id: 'test-uuid-1234', - feature_id: FeatureId.UNIFIED_SWAP_BRIDGE, - result: 'success', - }), - ); - expect( - providerTraces.map((request) => request.data?.provider), - ).toStrictEqual(['socket_across', 'hop_hop']); - expect( - providerTraces.every((request) => Number.isFinite(request.startTime)), - ).toBe(true); - }); - - it.each([ - { - name: 'same-chain success', - response: (): unknown => - mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]]), - request: { - ...quoteRequest, - destChainId: quoteRequest.srcChainId, - }, - traceName: TraceName.SwapQuotesFetched, - result: 'success', - providerCount: 1, - }, - { - name: 'no quotes', - response: (): unknown => mockSseEventSource([]), - request: quoteRequest, - traceName: TraceName.BridgeQuotesFetched, - result: 'no_quotes', - providerCount: 0, - }, - { - name: 'error', - response: (): unknown => mockSseServerError('provider request failed'), - request: quoteRequest, - traceName: TraceName.BridgeQuotesFetched, - result: 'error', - providerCount: 0, - }, - ])('records $name', async (scenario) => { - const requests = await runTraceScenario(scenario); - - expect(getTrace(requests, scenario.traceName)?.data).toStrictEqual( - expect.objectContaining({ - result: scenario.result, - }), - ); - expect( - requests.filter( - (request) => request.name === TraceName.QuoteProviderFirstResult, - ), - ).toHaveLength(scenario.providerCount); - }); - - it('records cancellation for an expected abort', async () => { - const requests = await runTraceScenario({ - response: (): unknown => - mockSseEventSource([mockBridgeQuotesErc20Erc20V1[0]], 10000), - abort: true, - }); - - expect( - getTrace(requests, TraceName.BridgeQuotesFetched)?.data, - ).toStrictEqual( - expect.objectContaining({ - result: 'cancelled', - }), - ); - }); - }); - it('should trigger quote polling if request is valid', async function () { await withController( async ({ diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 4e9146b425d..ce390808bf7 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2180,7 +2180,8 @@ describe('BridgeController', function () { ); // Trigger the fetch + abort rejection - await jest.advanceTimersByTimeAsync(1000); + jest.advanceTimersByTime(1000); + await flushPromises(); // Early return path: no post-fetch updates expect(fetchBridgeQuotesSpy).toHaveBeenCalledTimes(1); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 62f27167288..ce3dcbb4be4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -3,13 +3,12 @@ import { BigNumber } from '@ethersproject/bignumber'; import { Contract } from '@ethersproject/contracts'; import { Web3Provider } from '@ethersproject/providers'; import type { StateMetadata } from '@metamask/base-controller'; -import type { TraceCallback, TraceRequest } from '@metamask/controller-utils'; +import type { TraceCallback } from '@metamask/controller-utils'; import type { InternalAccount } from '@metamask/keyring-internal-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { TransactionController } from '@metamask/transaction-controller'; import type { CaipAssetType, Hex } from '@metamask/utils'; -import { v4 as uuid } from 'uuid'; import { toQuoteResponseV2 } from './coercers/quote-response-v1-to-v2.js'; import type { BridgeClientId } from './constants/bridge.js'; @@ -73,7 +72,6 @@ import { formatProviderLabel, getAccountHardwareType, getRequestParams, - getSwapType, getSwapTypeFromQuote, isCustomSlippage, toInputChangedPropertyKey, @@ -202,31 +200,6 @@ type BridgePollingInput = { RequiredEventContextFromClient[UnifiedSwapBridgeEventName.QuotesRequested]; }; -type QuoteTraceResult = 'success' | 'cancelled' | 'no_quotes' | 'error'; - -const QUOTE_ABORT_REASONS = new Set(Object.values(AbortReason)); - -const isExpectedQuoteAbort = ( - error: unknown, - signal?: AbortSignal, -): boolean => { - if (signal?.aborted) { - return true; - } - - if (QUOTE_ABORT_REASONS.has(String(error))) { - return true; - } - - const errorText = - error instanceof Error ? `${error.name} ${error.message}` : String(error); - - return ( - errorText.includes('AbortError') || - errorText.includes('FetchRequestCanceledException') - ); -}; - const MESSENGER_EXPOSED_METHODS = [ 'updateBridgeQuoteRequestParams', 'fetchQuotes', @@ -818,9 +791,6 @@ export class BridgeController extends StaticIntervalPollingController console.warn('Failed to fetch asset exchange rates', error), @@ -853,110 +823,83 @@ export class BridgeController extends StaticIntervalPollingController(); - const traceWithoutImpact = async (request: TraceRequest): Promise => { - try { - await this.#trace(request, () => undefined); - } catch { - // Telemetry failures must not affect quote fetching or state updates. - } - }; - const traceProviderFirstResult = ( - providerData: Parameters[0], - ) => { - const provider = formatProviderLabel(providerData); - if (isBatchSellRequest || tracedProviders.has(provider)) { - return; - } - tracedProviders.add(provider); - // Provider telemetry must not delay quote processing. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - traceWithoutImpact({ - name: TraceName.QuoteProviderFirstResult, - startTime: quoteTraceStartTime, - data: { - provider, - feature_id: context.feature_id, - request_id: quoteTraceRequestId, - swap_type: getSwapType( + + await this.#trace( + { + name: isBatchSellRequest + ? TraceName.BatchSellQuotesFetched + : unifiedSwapTraceName, + data: { + srcChainId: formatChainIdToCaip(firstQuoteRequest.srcChainId), + destChainId: formatChainIdToCaip(firstQuoteRequest.destChainId), + }, + }, + async () => { + const selectedAccount = this.#getMultichainSelectedAccount( + firstQuoteRequest.walletAddress, + ); + // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.#setMinimumBalanceForRentExemptionInLamports( firstQuoteRequest.srcChainId, - firstQuoteRequest.destChainId, - ), - srcChainId: formatChainIdToCaip(firstQuoteRequest.srcChainId), - destChainId: formatChainIdToCaip(firstQuoteRequest.destChainId), - result: 'success', + selectedAccount?.metadata?.snap?.id, + ); + // Use SSE if enabled and return early + if (shouldStream || isBatchSellRequest) { + await this.#handleQuoteStreaming( + quoteRequests, + context.feature_id, + jwt, + selectedAccount, + ); + return; + } + // Otherwise use regular fetch + const quotes = await this.fetchQuotes( + firstQuoteRequest, + context.feature_id, + this.#abortController?.signal, + ); + this.update((state) => { + // Set the initial load time if this is the first fetch + if ( + state.quotesRefreshCount === + DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && + this.#quotesFirstFetched + ) { + state.quotesInitialLoadTime = + Date.now() - this.#quotesFirstFetched; + } + state.quotes = quotes.map(toQuoteResponseV2); + state.quotesLoadingStatus = RequestStatus.FETCHED; + }); }, - }); - }; - - try { - const selectedAccount = this.#getMultichainSelectedAccount( - firstQuoteRequest.walletAddress, ); - // This call is not awaited to prevent blocking quote fetching if the snap takes too long to respond - // eslint-disable-next-line @typescript-eslint/no-floating-promises - this.#setMinimumBalanceForRentExemptionInLamports( - firstQuoteRequest.srcChainId, - selectedAccount?.metadata?.snap?.id, - ); - // Use SSE if enabled and return early - if (shouldStream || isBatchSellRequest) { - const quoteCount = await this.#handleQuoteStreaming({ - quoteRequests, - featureId: context.feature_id, - jwt, - selectedAccount, - signal: quoteAbortSignal, - traceProviderFirstResult, - }); - if (quoteAbortSignal.aborted) { - traceResult = 'cancelled'; - return; - } - traceResult = quoteCount > 0 ? 'success' : 'no_quotes'; - } else { - // Otherwise use regular fetch - const quotes = await this.fetchQuotes( - firstQuoteRequest, - context.feature_id, - quoteAbortSignal, - ); - for (const quote of quotes) { - traceProviderFirstResult(quote.quote); - } - this.update((state) => { - // Set the initial load time if this is the first fetch - if ( - state.quotesRefreshCount === - DEFAULT_BRIDGE_CONTROLLER_STATE.quotesRefreshCount && - this.#quotesFirstFetched - ) { - state.quotesInitialLoadTime = Date.now() - this.#quotesFirstFetched; - } - state.quotes = quotes.map(toQuoteResponseV2); - state.quotesLoadingStatus = RequestStatus.FETCHED; - }); - traceResult = quotes.length > 0 ? 'success' : 'no_quotes'; - } } catch (error) { // Reset the quotes list if the fetch fails to avoid showing stale quotes this.update((state) => { state.quotes = DEFAULT_BRIDGE_CONTROLLER_STATE.quotes; }); // Ignore abort errors - if (isExpectedQuoteAbort(error, quoteAbortSignal)) { - traceResult = 'cancelled'; + if ( + (error as Error).toString().includes('AbortError') || + (error as Error).toString().includes('FetchRequestCanceledException') || + [ + AbortReason.ResetState, + AbortReason.NewQuoteRequest, + AbortReason.QuoteRequestUpdated, + AbortReason.TransactionSubmitted, + ].includes(error as AbortReason) + ) { // Exit the function early to prevent other state updates return; } @@ -985,20 +928,6 @@ export class BridgeController extends StaticIntervalPollingController[0], - ) => void; - }): Promise => { + readonly #handleQuoteStreaming = async ( + quoteRequests: GenericQuoteRequest[], + featureId: FeatureId, + jwt?: string, + selectedAccount?: InternalAccount, + ) => { /** * Tracks the number of valid quotes received from the current stream, which is used * to determine when to clear the quotes list and set the initial load time @@ -1052,7 +970,7 @@ export class BridgeController extends StaticIntervalPollingController 0) { validQuotesCounter += 1; - traceProviderFirstResult(quote.quote); } this.update((state) => { // Clear previous quotes and quotes load time when first quote in the current @@ -1135,8 +1052,6 @@ export class BridgeController extends StaticIntervalPollingController - jest - .fn() - .mockImplementation( - async (request: TraceRequest, callback?: () => unknown) => { - traceRequests.push(request); - return await callback?.(); - }, - ); - -const getSwapOperationCompletedTrace = ( - traceRequests: TraceRequest[], -): TraceRequest | undefined => - traceRequests.find(({ name }) => name === TraceName.SwapOperationCompleted); - function getRootMessenger(): RootMessenger { return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); } @@ -1787,157 +1770,6 @@ describe('BridgeStatusController', () => { }); }); - describe('swap operation completion tracing', () => { - it.each([ - { - name: 'success', - result: 'success', - response: (): StatusResponse => MockStatusResponse.getComplete(), - destinationTxHash: '0xdestTxHash1', - }, - { - name: 'failure', - result: 'error', - response: (): StatusResponse => MockStatusResponse.getFailed(), - destinationTxHash: undefined, - }, - ])('records $name', async (scenario) => { - jest.useFakeTimers(); - const startTime = 1729964825189; - const completionTime = 1736277625746; - jest.spyOn(Date, 'now').mockImplementation(() => completionTime); - const traceRequests: TraceRequest[] = []; - - await withController( - { - options: { - traceFn: createTraceCallback(traceRequests), - }, - }, - async ({ rootMessenger }) => { - registerDefaultActionHandlers(rootMessenger); - jest - .spyOn(bridgeStatusUtils, 'fetchBridgeTxStatus') - .mockResolvedValueOnce({ - status: scenario.response(), - validationFailures: [], - }); - - rootMessenger.call( - 'BridgeStatusController:startPollingForBridgeTxStatus', - getMockStartPollingForBridgeTxStatusArgs(), - ); - jest.advanceTimersByTime(10000); - await flushPromises(); - - const trace = getSwapOperationCompletedTrace(traceRequests); - expect(trace).toStrictEqual( - expect.objectContaining({ - name: TraceName.SwapOperationCompleted, - startTime, - data: expect.objectContaining({ - srcChainId: 'eip155:42161', - destChainId: 'eip155:10', - provider: 'lifi_across', - swap_type: 'crosschain', - terminal_stage: 'destination', - quote_id: '197c402f-cb96-4096-9f8c-54aed84ca776', - transaction_id: 'bridgeTxMetaId1', - src_tx_hash: '0xsrcTxHash1', - result: scenario.result, - }), - }), - ); - expect(trace?.data?.dest_tx_hash ?? null).toBe( - scenario.destinationTxHash ?? null, - ); - expect( - traceRequests.filter( - ({ name }) => name === TraceName.SwapOperationCompleted, - ), - ).toHaveLength(1); - }, - ); - }); - - it.each([ - { - name: 'same-chain success', - history: () => MockTxHistory.getPendingSwap(), - transactionId: 'swapTxMetaId1', - transactionType: TransactionType.swap, - transactionStatus: TransactionStatus.confirmed, - result: 'success', - swapType: 'single_chain', - }, - { - name: 'cross-chain source failure', - history: () => MockTxHistory.getPending(), - transactionId: 'bridgeTxMetaId1', - transactionType: TransactionType.bridge, - transactionStatus: TransactionStatus.failed, - result: 'error', - swapType: 'crosschain', - }, - ])( - 'records $name', - async ({ - history, - transactionId, - transactionType, - transactionStatus, - result, - swapType, - }) => { - const traceRequests: TraceRequest[] = []; - - await withController( - { - options: { - state: { - txHistory: history(), - }, - traceFn: createTraceCallback(traceRequests), - }, - }, - async ({ rootMessenger }) => { - registerDefaultActionHandlers(rootMessenger); - rootMessenger.publish( - 'TransactionController:transactionStatusUpdated', - { - transactionMeta: { - chainId: CHAIN_IDS.ARBITRUM, - hash: '0xsourceTxHash', - networkClientId: 'eth-id', - time: Date.now(), - txParams: {} as unknown as TransactionParams, - type: transactionType, - status: transactionStatus, - id: transactionId, - } as TransactionMeta, - }, - ); - await flushPromises(); - - expect( - getSwapOperationCompletedTrace(traceRequests), - ).toStrictEqual( - expect.objectContaining({ - name: TraceName.SwapOperationCompleted, - data: expect.objectContaining({ - result, - swap_type: swapType, - terminal_stage: 'source', - transaction_id: transactionId, - }), - }), - ); - }, - ); - }, - ); - }); - it.each([ { status: TransactionStatus.confirmed, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 581976d1c81..a877b3d0862 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -99,14 +99,7 @@ import { getPreConfirmationPropertiesFromQuote, } from './utils/metrics.js'; import { getSelectedChainId } from './utils/network.js'; -import { - getSwapOperationCompletedTraceParams, - getTraceParams, -} from './utils/trace.js'; -import type { - SwapOperationResult, - SwapOperationTerminalStage, -} from './utils/trace.js'; +import { getTraceParams } from './utils/trace.js'; import { getTransactionMetaById, getTransactions, @@ -346,36 +339,6 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - if (!historyKey) { - return; - } - - const historyItem = this.state.txHistory[historyKey]; - const featureId = historyItem?.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE; - if ( - !historyItem || - historyItem.batchSellData || - !ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS.includes(featureId) - ) { - return; - } - - await this.#trace( - getSwapOperationCompletedTraceParams( - historyItem, - historyKey, - result, - terminalStage, - ), - () => undefined, - ); - }; - readonly #onTransactionFailed = ({ txMeta, historyKey, @@ -385,12 +348,10 @@ export class BridgeStatusController extends StaticIntervalPollingController { + // Check if the history item is already marked as a failure const isHistoryItemAlreadyFailed = historyKey ? this.state.txHistory[historyKey]?.status.status === StatusTypes.FAILED : false; - const isIntent = historyKey - ? Boolean(this.state.txHistory[historyKey]?.quote.intent) - : false; this.#updateHistoryItem({ historyKey, @@ -403,16 +364,12 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined, - ); - } - // Report finalized failure for swap/bridge transactions. // Note: TransactionStatus.rejected means the user cancelled signing, so the tx was never broadcast. // `hasNestedSwapTransactions` also covers batch/7702 swaps whose type may @@ -456,9 +413,6 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); - } this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Completed, historyKey, @@ -1155,17 +1102,6 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); - if (status.status === StatusTypes.COMPLETE) { this.#trackUnifiedSwapBridgeEvent( UnifiedSwapBridgeEventName.Completed, @@ -1394,11 +1330,6 @@ export class BridgeStatusController extends StaticIntervalPollingController undefined); this.#quoteStatusManager.reportFinalised( payload.historyKey, true, diff --git a/packages/bridge-status-controller/src/constants.ts b/packages/bridge-status-controller/src/constants.ts index da2696b42b6..c194658e892 100644 --- a/packages/bridge-status-controller/src/constants.ts +++ b/packages/bridge-status-controller/src/constants.ts @@ -23,8 +23,6 @@ export enum TraceName { BridgeTransactionCompleted = 'Bridge Transaction Completed', SwapTransactionApprovalCompleted = 'Swap Transaction Approval Completed', SwapTransactionCompleted = 'Swap Transaction Completed', - // For this constant only, "Swap" is the umbrella term for single-chain and cross-chain operations; use `swap_type` to distinguish them. - SwapOperationCompleted = 'Swap Operation Completed', } export const ALLOWED_FEATURE_IDS_FOR_STATUS_EVENTS = [ diff --git a/packages/bridge-status-controller/src/utils/trace.ts b/packages/bridge-status-controller/src/utils/trace.ts index 95800264e92..570cd46cf3a 100644 --- a/packages/bridge-status-controller/src/utils/trace.ts +++ b/packages/bridge-status-controller/src/utils/trace.ts @@ -1,18 +1,11 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ import { formatChainIdToCaip, - formatProviderLabel, - FeatureId, - getSwapType, isCrossChain, QuoteResponseV1, } from '@metamask/bridge-controller'; import { TraceName } from '../constants.js'; -import type { BridgeHistoryItem } from '../types.js'; - -export type SwapOperationResult = 'success' | 'error'; -export type SwapOperationTerminalStage = 'source' | 'destination'; export const getTraceParams = ( quoteResponse: QuoteResponseV1, @@ -28,7 +21,6 @@ export const getTraceParams = ( data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), stxEnabled: isStxEnabled, - feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, }, }; }; @@ -47,41 +39,6 @@ export const getApprovalTraceParams = ( data: { srcChainId: formatChainIdToCaip(quoteResponse.quote.srcChainId), stxEnabled: isStxEnabled, - feature_id: quoteResponse.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, - }, - }; -}; - -export const getSwapOperationCompletedTraceParams = ( - historyItem: BridgeHistoryItem, - historyKey: string, - result: SwapOperationResult, - terminalStage: SwapOperationTerminalStage, -) => { - const quoteId = historyItem.quoteId ?? historyItem.quote.requestId; - const sourceTransactionHash = historyItem.status.srcChain.txHash; - const destinationTransactionHash = historyItem.status.destChain?.txHash; - - return { - name: TraceName.SwapOperationCompleted, - startTime: historyItem.startTime, - data: { - srcChainId: formatChainIdToCaip(historyItem.quote.srcChainId), - destChainId: formatChainIdToCaip(historyItem.quote.destChainId), - feature_id: historyItem.featureId ?? FeatureId.UNIFIED_SWAP_BRIDGE, - provider: formatProviderLabel(historyItem.quote), - swap_type: getSwapType( - historyItem.quote.srcChainId, - historyItem.quote.destChainId, - ), - terminal_stage: terminalStage, - transaction_id: historyItem.txMetaId ?? historyKey, - result, - ...(quoteId ? { quote_id: quoteId } : {}), - ...(sourceTransactionHash ? { src_tx_hash: sourceTransactionHash } : {}), - ...(destinationTransactionHash - ? { dest_tx_hash: destinationTransactionHash } - : {}), }, }; }; diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index 2d7c4beb595..a9963833e93 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [4.0.0] diff --git a/packages/chomp-api-service/package.json b/packages/chomp-api-service/package.json index e016265262c..0cb9090ef96 100644 --- a/packages/chomp-api-service/package.json +++ b/packages/chomp-api-service/package.json @@ -58,7 +58,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index d3348fd7a6a..a8d400c1bc5 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -404,7 +404,7 @@ export class ChompApiService extends BaseDataService< * The result is scoped to the authenticated profile and consumers use it * to decide whether an association already exists, so it is always fetched * fresh (`staleTime: 0`) and evicted as soon as the call settles - * (`gcTime: 0`). The query key carries a SHA-256 digest of the bearer + * (`cacheTime: 0`). The query key carries a SHA-256 digest of the bearer * token — the same token the request is made with — so concurrent calls * only share an in-flight request when they are for the same profile. The * digest, not the token, is used because query keys leave the service via @@ -422,7 +422,7 @@ export class ChompApiService extends BaseDataService< const jsonResponse = await this.fetchQuery({ queryKey: [`${this.name}:getAssociatedAddresses`, profileKey], staleTime: 0, - gcTime: 0, + cacheTime: 0, queryFn: async () => { const response = await fetch( new URL('/v1/auth/address', this.#baseUrl), diff --git a/packages/claims-controller/CHANGELOG.md b/packages/claims-controller/CHANGELOG.md index 7b08a719c62..4dcaa111828 100644 --- a/packages/claims-controller/CHANGELOG.md +++ b/packages/claims-controller/CHANGELOG.md @@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - ## [0.6.0] ### Added diff --git a/packages/claims-controller/package.json b/packages/claims-controller/package.json index 3edf7d6f06c..0483871a68f 100644 --- a/packages/claims-controller/package.json +++ b/packages/claims-controller/package.json @@ -63,7 +63,7 @@ "@metamask/profile-sync-controller": "^29.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/client-utils/CHANGELOG.md b/packages/client-utils/CHANGELOG.md index e34b76bf86b..cf7e6530270 100644 --- a/packages/client-utils/CHANGELOG.md +++ b/packages/client-utils/CHANGELOG.md @@ -7,17 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [2.1.0] - -### Added - -- Add `stake` / `unstake` activity kinds and a `PerpsOrderKind` type covering every perps order kind ([#9916](https://github.com/MetaMask/core/pull/9916)) -- Add `ActivityItem` variants for staking, prediction, and perps activity kinds that previously had no matching data shape ([#9916](https://github.com/MetaMask/core/pull/9916)) - -### Changed - -- Bump `@metamask/core-backend` from `^8.1.1` to `^8.1.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) - ## [2.0.2] ### Changed @@ -139,8 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^68.2.2` to `^68.3.0` ([#9421](https://github.com/MetaMask/core/pull/9421)) - Bump `@metamask/keyring-api` from `^23.3.0` to `^23.5.0` ([#9390](https://github.com/MetaMask/core/pull/9390)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.1.0...HEAD -[2.1.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.2...@metamask/client-utils@2.1.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.2...HEAD [2.0.2]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.1...@metamask/client-utils@2.0.2 [2.0.1]: https://github.com/MetaMask/core/compare/@metamask/client-utils@2.0.0...@metamask/client-utils@2.0.1 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/client-utils@1.6.0...@metamask/client-utils@2.0.0 diff --git a/packages/client-utils/package.json b/packages/client-utils/package.json index c6b7b48f988..6d50492d8f9 100644 --- a/packages/client-utils/package.json +++ b/packages/client-utils/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/client-utils", - "version": "2.1.0", + "version": "2.0.2", "description": "Shared functions and utilities used across MetaMask clients (extension and mobile)", "keywords": [ "Ethereum", @@ -57,7 +57,7 @@ "dependencies": { "@metamask/contract-metadata": "^2.4.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/core-backend": "^8.1.2", + "@metamask/core-backend": "^8.1.1", "@metamask/keyring-api": "^24.0.0", "@metamask/slip44": "^4.3.0", "@metamask/transaction-controller": "^69.5.2", diff --git a/packages/client-utils/src/types.ts b/packages/client-utils/src/types.ts index c305e255e05..08c8c5c00c3 100644 --- a/packages/client-utils/src/types.ts +++ b/packages/client-utils/src/types.ts @@ -1,18 +1,6 @@ import type { ValueTransfer as _ValueTransfer } from '@metamask/core-backend'; import type { CaipChainId } from '@metamask/utils'; -export type PerpsOrderKind = - | 'marketShort' - | 'stopMarketCloseShort' - | 'marketCloseShort' - | 'limitShort' - | 'limitCloseShort' - | 'marketLong' - | 'stopMarketCloseLong' - | 'marketCloseLong' - | 'limitLong' - | 'limitCloseLong'; - export type ActivityKind = | 'receive' | 'sell' @@ -37,8 +25,6 @@ export type ActivityKind = | 'smartAccountUpgrade' | 'lendingDeposit' | 'lendingWithdrawal' - | 'stake' - | 'unstake' | 'predictionsAddFunds' | 'predictionsWithdrawFunds' | 'predictionClaimWinnings' @@ -58,7 +44,9 @@ export type ActivityKind = | 'perpsReceivedFundingFees' | 'perpsCloseShortTakeProfit' | 'perpsCloseLongTakeProfit' - | PerpsOrderKind + | 'marketShort' + | 'stopMarketCloseShort' + | 'marketCloseShort' | 'assetActivation' | 'assetDeactivation' | 'rampBuy' @@ -176,39 +164,6 @@ export type ActivityItem = token?: TokenAmount; } > - | ActivityData< - | 'stake' - | 'unstake' - | 'sell' - | 'contractDeployment' - | 'smartAccountUpgrade' - | 'predictionsAddFunds' - | 'predictionsWithdrawFunds' - | 'predictionClaimWinnings' - | 'predictionCashedOut' - | 'predictionPlaced' - | 'perpsOpenLong' - | 'perpsCloseLong' - | 'perpsCloseLongLiquidated' - | 'perpsCloseLongStopLoss' - | 'perpsOpenShort' - | 'perpsCloseShort' - | 'perpsCloseShortLiquidated' - | 'perpsCloseShortStopLoss' - | 'perpsPaidFundingFees' - | 'perpsReceivedFundingFees' - | 'perpsCloseShortTakeProfit' - | 'perpsCloseLongTakeProfit' - | PerpsOrderKind, - { - from?: string; - to?: string; - token?: TokenAmount; - sourceToken?: TokenAmount; - destinationToken?: TokenAmount; - fees?: Fee[]; - } - > | ActivityData< 'contractInteraction', { diff --git a/packages/config-registry-controller/CHANGELOG.md b/packages/config-registry-controller/CHANGELOG.md index 92b376cba49..f3d6629e2e5 100644 --- a/packages/config-registry-controller/CHANGELOG.md +++ b/packages/config-registry-controller/CHANGELOG.md @@ -7,16 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [3.0.0] - -### Added - -- Add `ConfigRegistryApiEnv` enum to select the API environment for the service ([#9918](https://github.com/MetaMask/core/pull/9918)) - ### Changed -- **BREAKING:** The `env` optional constructor option type is now `ConfigRegistryApiEnv` ([#9918](https://github.com/MetaMask/core/pull/9918)) - - Previously, constructor options were reusing the `SDK.Env` enum from `@metamask/profile-sync-controller`. - Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) @@ -143,8 +135,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#7668](https://github.com/MetaMask/core/pull/7668), [#7809](https://github.com/MetaMask/core/pull/7809)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@3.0.0...HEAD -[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@2.0.1...@metamask/config-registry-controller@3.0.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@2.0.1...HEAD [2.0.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@2.0.0...@metamask/config-registry-controller@2.0.1 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@1.0.1...@metamask/config-registry-controller@2.0.0 [1.0.1]: https://github.com/MetaMask/core/compare/@metamask/config-registry-controller@1.0.0...@metamask/config-registry-controller@1.0.1 diff --git a/packages/config-registry-controller/package.json b/packages/config-registry-controller/package.json index a8225390605..2d5804e37eb 100644 --- a/packages/config-registry-controller/package.json +++ b/packages/config-registry-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/config-registry-controller", - "version": "3.0.0", + "version": "2.0.1", "description": "Manages configuration registry for MetaMask", "keywords": [ "Ethereum", @@ -61,6 +61,7 @@ "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/polling-controller": "^16.0.9", + "@metamask/profile-sync-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts index eb44697c0ff..efcdf648bfc 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.test.ts @@ -1,10 +1,8 @@ +import { SDK } from '@metamask/profile-sync-controller'; import nock from 'nock'; import { createMockNetworkConfig } from '../../tests/helpers.js'; -import { - ConfigRegistryApiEnv, - ConfigRegistryApiService, -} from './config-registry-api-service.js'; +import { ConfigRegistryApiService } from './config-registry-api-service.js'; import type { ConfigRegistryApiServiceMessenger, ConfigRegistryApiServiceOptions, @@ -47,7 +45,7 @@ describe('ConfigRegistryApiService', () => { .get(CONFIG_PATH) .reply(200, MOCK_API_RESPONSE); - const service = createService({ env: ConfigRegistryApiEnv.UAT }); + const service = createService({ env: SDK.Env.UAT }); await service.fetchConfig(); expect(scope.isDone()).toBe(true); }); @@ -57,7 +55,7 @@ describe('ConfigRegistryApiService', () => { .get(CONFIG_PATH) .reply(200, MOCK_API_RESPONSE); - const service = createService({ env: ConfigRegistryApiEnv.DEV }); + const service = createService({ env: SDK.Env.DEV }); await service.fetchConfig(); expect(scope.isDone()).toBe(true); }); @@ -67,7 +65,7 @@ describe('ConfigRegistryApiService', () => { .get(CONFIG_PATH) .reply(200, MOCK_API_RESPONSE); - const service = createService({ env: ConfigRegistryApiEnv.PRD }); + const service = createService({ env: SDK.Env.PRD }); await service.fetchConfig(); expect(scope.isDone()).toBe(true); }); diff --git a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts index e6fce4d49a9..1c8767ec9f0 100644 --- a/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts +++ b/packages/config-registry-controller/src/config-registry-api-service/config-registry-api-service.ts @@ -4,6 +4,7 @@ import type { ServicePolicy, } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import { SDK } from '@metamask/profile-sync-controller'; import type { IDisposable } from 'cockatiel'; import type { ConfigRegistryApiServiceMethodActions } from './config-registry-api-service-method-action-types.js'; @@ -16,12 +17,6 @@ import { validateRegistryConfigApiResponse } from './types.js'; const ENDPOINT_PATH = '/config/networks'; -export enum ConfigRegistryApiEnv { - DEV = 'dev', - UAT = 'uat', - PRD = 'prod', -} - /** * The name of the {@link ConfigRegistryApiService}, used to namespace the * service's actions and events. @@ -71,8 +66,8 @@ export type ConfigRegistryApiServiceMessenger = Messenger< * @param env - The environment to get the URL for. * @returns The base URL for the environment. */ -function getConfigRegistryUrl(env: ConfigRegistryApiEnv): string { - const envPrefix = env === ConfigRegistryApiEnv.PRD ? '' : `${env}-`; +function getConfigRegistryUrl(env: SDK.Env): string { + const envPrefix = env === SDK.Env.PRD ? '' : `${env}-`; return `https://client-config.${envPrefix}api.cx.metamask.io/v1${ENDPOINT_PATH}`; } @@ -82,7 +77,7 @@ export type ConfigRegistryApiServiceOptions = { * independently and register its actions. */ messenger: ConfigRegistryApiServiceMessenger; - env?: ConfigRegistryApiEnv; + env?: SDK.Env; fetch?: typeof fetch; /** * Options to pass to `createServicePolicy`, which wraps each request. @@ -116,7 +111,7 @@ export class ConfigRegistryApiService { */ constructor({ messenger, - env = ConfigRegistryApiEnv.UAT, + env = SDK.Env.UAT, fetch: customFetch = globalThis.fetch, policyOptions = {}, }: ConfigRegistryApiServiceOptions) { diff --git a/packages/config-registry-controller/src/index.ts b/packages/config-registry-controller/src/index.ts index 646387691db..f107ae4abc9 100644 --- a/packages/config-registry-controller/src/index.ts +++ b/packages/config-registry-controller/src/index.ts @@ -33,8 +33,5 @@ export type { ConfigRegistryApiServiceMethodActions, } from './config-registry-api-service/config-registry-api-service-method-action-types.js'; export type { NetworkFilterOptions } from './config-registry-api-service/filters.js'; -export { - ConfigRegistryApiService, - ConfigRegistryApiEnv, -} from './config-registry-api-service/config-registry-api-service.js'; +export { ConfigRegistryApiService } from './config-registry-api-service/config-registry-api-service.js'; export { filterNetworks } from './config-registry-api-service/filters.js'; diff --git a/packages/config-registry-controller/tsconfig.build.json b/packages/config-registry-controller/tsconfig.build.json index 6edbd4404a9..08b48b2b526 100644 --- a/packages/config-registry-controller/tsconfig.build.json +++ b/packages/config-registry-controller/tsconfig.build.json @@ -12,6 +12,7 @@ { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../polling-controller/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/config-registry-controller/tsconfig.json b/packages/config-registry-controller/tsconfig.json index c4258e1ff1f..05f86e783c9 100644 --- a/packages/config-registry-controller/tsconfig.json +++ b/packages/config-registry-controller/tsconfig.json @@ -9,6 +9,7 @@ { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../polling-controller" }, + { "path": "../profile-sync-controller" }, { "path": "../remote-feature-flag-controller" } ], "include": ["../../types", "./src", "./tests"] diff --git a/packages/core-backend/CHANGELOG.md b/packages/core-backend/CHANGELOG.md index fb725b9b576..8f7d0780613 100644 --- a/packages/core-backend/CHANGELOG.md +++ b/packages/core-backend/CHANGELOG.md @@ -7,11 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [8.1.2] - ### Changed -- Bump `@metamask/account-tree-controller` from `^7.6.0` to `^8.0.0` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9886](https://github.com/MetaMask/core/pull/9886)) +- Bump `@metamask/account-tree-controller` from `^7.6.0` to `^7.6.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) ## [8.1.1] @@ -390,8 +388,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Type definitions** - Comprehensive TypeScript types for transactions, balances, WebSocket messages, and service configurations - **Logging infrastructure** - Structured logging with module-specific loggers for debugging and monitoring -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.2...HEAD -[8.1.2]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.1...@metamask/core-backend@8.1.2 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.1...HEAD [8.1.1]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.1.0...@metamask/core-backend@8.1.1 [8.1.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@8.0.0...@metamask/core-backend@8.1.0 [8.0.0]: https://github.com/MetaMask/core/compare/@metamask/core-backend@7.0.0...@metamask/core-backend@8.0.0 diff --git a/packages/core-backend/package.json b/packages/core-backend/package.json index af9f6eb7f7c..bf409003bb4 100644 --- a/packages/core-backend/package.json +++ b/packages/core-backend/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-backend", - "version": "8.1.2", + "version": "8.1.1", "description": "Core backend services for MetaMask", "keywords": [ "Ethereum", @@ -55,7 +55,7 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@metamask/account-tree-controller": "^8.0.0", + "@metamask/account-tree-controller": "^7.6.1", "@metamask/controller-utils": "^12.3.0", "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", diff --git a/packages/earn-controller/CHANGELOG.md b/packages/earn-controller/CHANGELOG.md index 2d2a740826a..65cef0aceaf 100644 --- a/packages/earn-controller/CHANGELOG.md +++ b/packages/earn-controller/CHANGELOG.md @@ -7,12 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [12.2.5] - -### Changed - -- Bump `@metamask/account-tree-controller` from `^7.6.1` to `^8.0.0` ([#9886](https://github.com/MetaMask/core/pull/9886)) - ### Fixed - Avoid duplicate `refreshEarnEligibility`/`refreshPooledStakes`/`refreshLendingPositions` calls when `AccountTreeController:selectedAccountGroupChange` fires with an address that was already just refreshed (e.g. immediately after `init()` during startup hydration) ([#9804](https://github.com/MetaMask/core/pull/9804)) @@ -503,8 +497,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5271](https://github.com/MetaMask/core/pull/5271)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.5...HEAD -[12.2.5]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.4...@metamask/earn-controller@12.2.5 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.4...HEAD [12.2.4]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.3...@metamask/earn-controller@12.2.4 [12.2.3]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.2...@metamask/earn-controller@12.2.3 [12.2.2]: https://github.com/MetaMask/core/compare/@metamask/earn-controller@12.2.1...@metamask/earn-controller@12.2.2 diff --git a/packages/earn-controller/package.json b/packages/earn-controller/package.json index f8207fd7cbc..daae19dad2d 100644 --- a/packages/earn-controller/package.json +++ b/packages/earn-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/earn-controller", - "version": "12.2.5", + "version": "12.2.4", "description": "Manages state for earning features and coordinates interactions between staking services, SDK integrations, and other controllers to enable users to participate in various earning opportunities", "keywords": [ "Ethereum", @@ -57,7 +57,7 @@ "dependencies": { "@ethersproject/bignumber": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/account-tree-controller": "^8.0.0", + "@metamask/account-tree-controller": "^7.6.1", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", "@metamask/keyring-api": "^24.0.0", diff --git a/packages/ens-controller/CHANGELOG.md b/packages/ens-controller/CHANGELOG.md new file mode 100644 index 00000000000..b1cc8d135db --- /dev/null +++ b/packages/ens-controller/CHANGELOG.md @@ -0,0 +1,474 @@ +# 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] + +### Changed + +- Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) + +## [19.1.6] + +### Changed + +- Bump `@metamask/messenger` from `^1.2.0` to `^2.0.0` ([#9392](https://github.com/MetaMask/core/pull/9392)) +- Bump `@metamask/network-controller` from `^34.0.0` to `^35.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) + +## [19.1.5] + +### Changed + +- Bump `@metamask/network-controller` from `^33.0.0` to `^34.0.0` ([#9349](https://github.com/MetaMask/core/pull/9349)) + +## [19.1.4] + +### Changed + +- Bump `@metamask/utils` from `^11.9.0` to `^11.11.0` ([#9074](https://github.com/MetaMask/core/pull/9074)) +- Bump `@metamask/controller-utils` from `^12.1.0` to `^12.3.0` ([#9058](https://github.com/MetaMask/core/pull/9058), [#9083](https://github.com/MetaMask/core/pull/9083), [#9218](https://github.com/MetaMask/core/pull/9218)) +- Bump `@metamask/network-controller` from `^32.0.0` to `^33.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218)) + +## [19.1.3] + +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^32.0.0` ([#8765](https://github.com/MetaMask/core/pull/8765), [#8774](https://github.com/MetaMask/core/pull/8774)) +- Bump `@metamask/controller-utils` from `^12.0.0` to `^12.1.0` ([#8774](https://github.com/MetaMask/core/pull/8774)) + +## [19.1.2] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.19.0` to `^12.0.0` ([#8344](https://github.com/MetaMask/core/pull/8344), [#8755](https://github.com/MetaMask/core/pull/8755)) +- Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) +- Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) +- Bump `@metamask/network-controller` from `^30.0.1` to `^31.0.0` ([#8636](https://github.com/MetaMask/core/pull/8636), [#8755](https://github.com/MetaMask/core/pull/8755)) + +## [19.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^9.0.0` to `^9.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/messenger` from `^0.3.0` to `^1.0.0` ([#8317](https://github.com/MetaMask/core/pull/8317)) +- Bump `@metamask/network-controller` from `^30.0.0` to `^30.0.1` ([#8317](https://github.com/MetaMask/core/pull/8317)) + +## [19.1.0] + +### Added + +- Expose missing public `EnsController` methods through its messenger ([#8183](https://github.com/MetaMask/core/pull/8183)) + - The following actions are now available: + - `EnsController:resetState` + - `EnsController:clear` + - `EnsController:delete` + - `EnsController:get` + - `EnsController:set` + - `EnsController:reverseResolveAddress` + - Corresponding action types (e.g. `EnsControllerResetStateAction`) are available as well. + +## [19.0.3] + +### Changed + +- Bump `@metamask/network-controller` from `^29.0.0` to `^30.0.0` ([#7996](https://github.com/MetaMask/core/pull/7996)) +- Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) + +## [19.0.2] + +### Changed + +- Bump `@metamask/network-controller` from `^28.0.0` to `^29.0.0` ([#7642](https://github.com/MetaMask/core/pull/7642)) + +## [19.0.1] + +### Changed + +- Upgrade `@metamask/utils` from `^11.8.1` to `^11.9.0` ([#7511](https://github.com/MetaMask/core/pull/7511)) +- Move peer dependencies for controller and service packages to direct dependencies ([#7209](https://github.com/MetaMask/core/pull/7209), [#7258](https://github.com/MetaMask/core/pull/7258), [#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583), [#7604](https://github.com/MetaMask/core/pull/7604)) + - The dependencies moved are: + - `@metamask/network-controller` (^28.0.0) + - In clients, it is now possible for multiple versions of these packages to exist in the dependency tree. + - For example, this scenario would be valid: a client relies on `@metamask/controller-a` 1.0.0 and `@metamask/controller-b` 1.0.0, and `@metamask/controller-b` depends on `@metamask/controller-a` 1.1.0. + - Note, however, that the versions specified in the client's `package.json` always "win", and you are expected to keep them up to date so as not to break controller and service intercommunication. +- Bump `@metamask/controller-utils` from `^11.16.0` to `^11.18.0` ([#7534](https://github.com/MetaMask/core/pull/7534), [#7583](https://github.com/MetaMask/core/pull/7583)) + +## [19.0.0] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.15.0` to `^11.16.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) +- **BREAKING:** Bump `@metamask/network-controller` from `^25.0.0` to `^26.0.0` ([#7202](https://github.com/MetaMask/core/pull/7202)) + +## [18.0.0] + +### Added + +- Export types `EnsControllerActions` and `EnsControllerEvents` ([#6460](https://github.com/MetaMask/core/pull/6460)) + +### Changed + +- **BREAKING:** Use new `Messenger` from `@metamask/messenger` ([#6460](https://github.com/MetaMask/core/pull/6460)) + - Previously, `EnsController` accepted a `RestrictedMessenger` instance from `@metamask/base-controller`. +- **BREAKING:** Metadata property `anonymous` renamed to `includeInDebugSnapshot` ([#6460](https://github.com/MetaMask/core/pull/6460)) +- **BREAKING:** Bump `@metamask/network-controller` from `^24.0.0` to `^25.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) +- Bump `@metamask/base-controller` from `^8.4.2` to `^9.0.0` ([#6962](https://github.com/MetaMask/core/pull/6962)) + +## [17.1.1] + +### Changed + +- Bump `@metamask/base-controller` from `^8.4.1` to `^8.4.2` ([#6917](https://github.com/MetaMask/core/pull/6917)) +- Bump `@metamask/network-controller` from `^24.2.2` to `^24.3.0` ([#6883](https://github.com/MetaMask/core/pull/6883)) + +## [17.1.0] + +### Added + +- Add two new controller state metadata properties: `includeInStateLogs` and `usedInUi` ([#6473](https://github.com/MetaMask/core/pull/6473)) + +### Changed + +- Bump `@metamask/base-controller` from `^8.0.1` to `^8.4.1` ([#6284](https://github.com/MetaMask/core/pull/6284), [#6355](https://github.com/MetaMask/core/pull/6355), [#6465](https://github.com/MetaMask/core/pull/6465), [#6632](https://github.com/MetaMask/core/pull/6632), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/controller-utils` from `^11.11.0` to `^11.14.1` ([#6303](https://github.com/MetaMask/core/pull/6303), [#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629), [#6807](https://github.com/MetaMask/core/pull/6807)) +- Bump `@metamask/utils` from `^11.4.2` to `^11.8.1` ([#6588](https://github.com/MetaMask/core/pull/6588), [#6708](https://github.com/MetaMask/core/pull/6708)) + +## [17.0.1] + +### Changed + +- Bump `@metamask/controller-utils` from `^11.10.0` to `^11.11.0` ([#6069](https://github.com/MetaMask/core/pull/6069)) + - This upgrade includes performance improvements to checksum hex address normalization +- Bump `@metamask/utils` from `^11.2.0` to `^11.4.2` ([#6054](https://github.com/MetaMask/core/pull/6054)) + +## [17.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^24.0.0` ([#5999](https://github.com/MetaMask/core/pull/5999)) +- Bump `@metamask/base-controller` to `^8.0.1` ([#5722](https://github.com/MetaMask/core/pull/5722)) +- Bump `@metamask/controller-utils` to `^11.10.0` ([#5935](https://github.com/MetaMask/core/pull/5935), [#5583](https://github.com/MetaMask/core/pull/5583), [#5765](https://github.com/MetaMask/core/pull/5765), [#5812](https://github.com/MetaMask/core/pull/5812)) + +## [16.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^23.0.0` ([#5507](https://github.com/MetaMask/core/pull/5507)) +- Bump `@metamask/controller-utils` to `^11.6.0` ([#5439](https://github.com/MetaMask/core/pull/5439)) +- Bump `@metamask/utils` to `^11.2.0` ([#5301](https://github.com/MetaMask/core/pull/5301)) + +## [15.0.2] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.2` to `^8.0.0`,, ([#5079](https://github.com/MetaMask/core/pull/5079), [#5135](https://github.com/MetaMask/core/pull/5135), [#5305](https://github.com/MetaMask/core/pull/5305)) +- Bump `@metamask/controller-utils` from `^11.4.4` to `^11.5.0`, ([#5135](https://github.com/MetaMask/core/pull/5135), [#5272](https://github.com/MetaMask/core/pull/5272)) +- Bump `@metamask/utils` from `^10.0.0` to `^11.1.0`, ([#5080](https://github.com/MetaMask/core/pull/5080), [#5223](https://github.com/MetaMask/core/pull/5223)) + +## [15.0.1] + +### Changed + +- Bump `@metamask/base-controller` from `^7.0.1` to `^7.0.2` ([#4862](https://github.com/MetaMask/core/pull/4862)) +- Bump `@metamask/controller-utils` from `^11.4.0` to `^11.4.4` ([#4862](https://github.com/MetaMask/core/pull/4862), [#4870](https://github.com/MetaMask/core/pull/4870), [#4915](https://github.com/MetaMask/core/pull/4915), [#5012](https://github.com/MetaMask/core/pull/5012)) + +### Fixed + +- Correct ESM-compatible build so that imports of the following packages that re-export other modules via `export *` are no longer corrupted: ([#5011](https://github.com/MetaMask/core/pull/5011)) + - `punycode/punycode.js` + +## [15.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/network-controller` peer dependency to `^22.0.0` ([#4841](https://github.com/MetaMask/core/pull/4841)) +- Bump `@metamask/controller-utils` to `^11.4.0` ([#4834](https://github.com/MetaMask/core/pull/4834)) +- Bump `@metamask/utils` to `^10.0.0` ([#4831](https://github.com/MetaMask/core/pull/4831)) + +## [14.0.1] + +### Fixed + +- Produce and export ESM-compatible TypeScript type declaration files in addition to CommonJS-compatible declaration files ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, this package shipped with only one variant of type declaration + files, and these files were only CommonJS-compatible, and the `exports` + field in `package.json` linked to these files. This is an anti-pattern and + was rightfully flagged by the + ["Are the Types Wrong?"](https://arethetypeswrong.github.io/) tool as + ["masquerading as CJS"](https://github.com/arethetypeswrong/arethetypeswrong.github.io/blob/main/docs/problems/FalseCJS.md). + All of the ATTW checks now pass. +- Remove chunk files. ([#4648](https://github.com/MetaMask/core/pull/4648)) + - Previously, the build tool we used to generate JavaScript files extracted + common code to "chunk" files. While this was intended to make this package + more tree-shakeable, it also made debugging more difficult for our + development teams. These chunk files are no longer present. + +## [14.0.0] + +### Changed + +- **BREAKING:** `EnsControllerMessenger` must allow `NetworkController:getState` action ([#4557](https://github.com/MetaMask/core/pull/4557)) +- **BREAKING:** Bump devDependency and peerDependency `@metamask/network-controller` from `^20.0.0` to `^21.0.0` ([#4618](https://github.com/MetaMask/core/pull/4618), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `@metamask/base-controller` from `^6.0.2` to `^7.0.0` ([#4625](https://github.com/MetaMask/core/pull/4625), [#4643](https://github.com/MetaMask/core/pull/4643)) +- Bump `@metamask/controller-utils` from `^11.0.2` to `^11.2.0` ([#4639](https://github.com/MetaMask/core/pull/4639), [#4651](https://github.com/MetaMask/core/pull/4651)) +- Bump `typescript` from `~5.0.4` to `~5.2.2` ([#4576](https://github.com/MetaMask/core/pull/4576), [#4584](https://github.com/MetaMask/core/pull/4584)) + +### Removed + +- **BREAKING:** Remove optional constructor option `provider` ([#4557](https://github.com/MetaMask/core/pull/4557)) + - Provider is now sourced from `selectedNetworkClient`. + +### Fixed + +- Initial network is set using `selectedNetworkClientId`, which is derived using the `NetworkController:getState` action ([#4557](https://github.com/MetaMask/core/pull/4557)) + +## [13.0.1] + +### Changed + +- Upgrade TypeScript version to `~5.0.4` and set `moduleResolution` option to `Node16` ([#3645](https://github.com/MetaMask/core/pull/3645)) +- Bump `@metamask/base-controller` from `^6.0.0` to `^6.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/controller-utils` from `^11.0.0` to `^11.0.2` ([#4517](https://github.com/MetaMask/core/pull/4517), [#4544](https://github.com/MetaMask/core/pull/4544)) +- Bump `@metamask/utils` from `^8.3.0` to `^9.1.0` ([#4516](https://github.com/MetaMask/core/pull/4516), [#4529](https://github.com/MetaMask/core/pull/4529)) + +## [13.0.0] + +### Changed + +- **BREAKING:** Bump peerDependency `@metamask/network-controller` to `^20.0.0` ([#4508](https://github.com/MetaMask/core/pull/4508)) + +## [12.0.0] + +### Changed + +- **BREAKING:** Bump minimum Node version to 18.18 ([#3611](https://github.com/MetaMask/core/pull/3611)) +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^19.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/base-controller` to `^6.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) +- Bump `@metamask/controller-utils` to `^11.0.0` ([#4352](https://github.com/MetaMask/core/pull/4352)) + +## [11.0.0] + +### Changed + +- **BREAKING:** Bump peer dependency `@metamask/network-controller` to `^18.1.3` ([#4342](https://github.com/MetaMask/core/pull/4342)) +- Bump `@metamask/base-controller` to `^5.0.2` ([#4232](https://github.com/MetaMask/core/pull/4232)) +- Bump `@metamask/controller-utils` to `^10.0.0` ([#4342](https://github.com/MetaMask/core/pull/4342)) + +### Fixed + +- Fix `delete` method to protect against prototype-polluting assignments ([#4041](https://github.com/MetaMask/core/pull/4041) + +## [10.0.1] + +### Fixed + +- Fix `types` field in `package.json` ([#4047](https://github.com/MetaMask/core/pull/4047)) + +## [10.0.0] + +### Added + +- **BREAKING**: Add ESM build ([#3998](https://github.com/MetaMask/core/pull/3998)) + - It's no longer possible to import files from `./dist` directly. +- Add support for Holesky and Sepolia registries ([#4006](https://github.com/MetaMask/core/pull/4006)) +- Add optional constructor option `registriesByChainId`, which allows overriding the default ENS network map ([#4006](https://github.com/MetaMask/core/pull/4006)) +- Update default value of `ensEntries` state property to include entry for `.` ([#4006](https://github.com/MetaMask/core/pull/4006)) +- Update `get` so that it now returns registry address for chain when queried for the name `.` ([#4006](https://github.com/MetaMask/core/pull/4006)) +- Update `delete` so that entry for `.` can be removed ([#4006](https://github.com/MetaMask/core/pull/4006)) + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to `^5.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + - This version has a number of breaking changes. See the changelog for more. +- **BREAKING:** Bump peer dependency on `@metamask/network-controller` to `^18.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) +- Bump `@metamask/controller-utils` to `^9.0.0` ([#4039](https://github.com/MetaMask/core/pull/4039)) + +## [9.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/network-controller` peer dependency to `^17.2.0` ([#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/utils` to `^8.3.0` ([#3769](https://github.com/MetaMask/core/pull/3769)) +- Bump `@metamask/base-controller` to `^4.1.1` ([#3760](https://github.com/MetaMask/core/pull/3760), [#3821](https://github.com/MetaMask/core/pull/3821)) +- Bump `@metamask/controller-utils` to `^8.0.2` ([#3821](https://github.com/MetaMask/core/pull/3821)) + +## [8.0.0] + +### Changed + +- **BREAKING:** Replace constructor parameter `onNetworkStateChange` with `onNetworkDidChange` ([#3610](https://github.com/MetaMask/core/pull/3610)) +- **BREAKING:** Bump `@metamask/network-controller` peer dependency from `^17.0.0` to `^17.1.0` ([#3695](https://github.com/MetaMask/core/pull/3695)) +- Bump `@metamask/controller-utils` to `^8.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695), [#3678](https://github.com/MetaMask/core/pull/3678), [#3667](https://github.com/MetaMask/core/pull/3667), [#3580](https://github.com/MetaMask/core/pull/3580)) +- Bump `@metamask/base-controller` to `^4.0.1` ([#3695](https://github.com/MetaMask/core/pull/3695)) + +### Fixed + +- Remove `@metamask/network-controller` dependency ([#3607](https://github.com/MetaMask/core/pull/3607)) + +## [7.0.0] + +### Changed + +- **BREAKING:** Bump `@metamask/base-controller` to ^4.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + - This is breaking because the type of the `messenger` has backward-incompatible changes. See the changelog for this package for more. +- Bump `@metamask/controller-utils` to ^6.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) +- Bump `@metamask/network-controller` to ^17.0.0 ([#2063](https://github.com/MetaMask/core/pull/2063)) + +## [6.0.1] + +### Changed + +- **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to ^16.0.0 +- Bump @metamask/utils from 8.1.0 to 8.2.0 ([#1957](https://github.com/MetaMask/core/pull/1957)) + +## [6.0.0] + +### Changed + +- **BREAKING:** Bump dependency and peer dependency on `@metamask/network-controller` to ^15.0.0 + +## [5.0.2] + +### Changed + +- Bump dependency on `@metamask/utils` to ^8.1.0 ([#1639](https://github.com/MetaMask/core/pull/1639)) +- Bump dependency on `@metamask/base-controller` to ^3.2.3 +- Bump dependency on `@metamask/controller-utils` to ^5.0.2 +- Bump dependency and peer dependency on `@metamask/network-controller` to ^14.0.0 + +## [5.0.1] + +### Changed + +- Update TypeScript to v4.8.x ([#1718](https://github.com/MetaMask/core/pull/1718)) + +## [5.0.0] + +### Changed + +- **BREAKING**: Bump peer dependency on `@metamask/network-controller` to ^13.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) +- Use `providerConfig.chainId` instead of `providerConfig.networkId` to determine ENS compatability ([#1633](https://github.com/MetaMask/core/pull/1633)) +- Bump dependency on `@metamask/controller-utils` to ^5.0.0 ([#1633](https://github.com/MetaMask/core/pull/1633)) + +## [4.1.1] + +### Changed + +- Bump dependency on `@metamask/base-controller` to ^3.2.1 +- Bump dependency on `@metamask/controller-utils` to ^4.3.2 +- Bump dependency and peer dependency on `@metamask/network-controller` to ^12.1.2 + +## [4.1.0] + +### Changed + +- Update `@metamask/utils` to `^6.2.0` ([#1514](https://github.com/MetaMask/core/pull/1514)) + +## [4.0.0] + +### Changed + +- **BREAKING:** Bump to Node 16 ([#1262](https://github.com/MetaMask/core/pull/1262)) +- **BREAKING:** Add `@metamask/network-controller` as a dependency and peer dependency ([#1367](https://github.com/MetaMask/core/pull/1367), [#1362](https://github.com/MetaMask/core/pull/1362)) +- **BREAKING:** The `ensEntries` state property is now keyed by `Hex` chain ID rather than `string`, and the `chainId` property of each ENS entry is also `Hex` rather than `string`. ([#1367](https://github.com/MetaMask/core/pull/1367)) + - This requires a state migration +- **BREAKING:** The methods `get`, `set`, and `delete` have been updated to accept and return chain IDs as 0x-prefixed hex strings, rather than decimal strings. ([#1367](https://github.com/MetaMask/core/pull/1367)) +- Bump @metamask/utils from 5.0.1 to 5.0.2 ([#1271](https://github.com/MetaMask/core/pull/1271)) + +### Fixed + +- Fix ENS controller failure to initialize after switching networks ([#1362](https://github.com/MetaMask/core/pull/1362)) + +## [3.1.0] + +### Changed + +- Add support for reverse ENS address resolution ([#1170](https://github.com/MetaMask/core/pull/1170)) + - This controller can now resolve a network address to an ENS address. This feature was ported from the extension ENS controller. + +## [3.0.0] + +### Changed + +- **BREAKING:** Convert the ENS controller to the BaseController v2 API ([#1134](https://github.com/MetaMask/core/pull/1134)) + +## [2.0.0] + +### Removed + +- **BREAKING:** Remove `isomorphic-fetch` ([#1106](https://github.com/MetaMask/controllers/pull/1106)) + - Consumers must now import `isomorphic-fetch` or another polyfill themselves if they are running in an environment without `fetch` + +## [1.0.2] + +### Changed + +- Rename this repository to `core` ([#1031](https://github.com/MetaMask/controllers/pull/1031)) +- Update `@metamask/controller-utils` package ([#1041](https://github.com/MetaMask/controllers/pull/1041)) + +## [1.0.1] + +### Changed + +- Relax dependencies on `@metamask/base-controller` and `@metamask/controller-utils` (use `^` instead of `~`) ([#998](https://github.com/MetaMask/core/pull/998)) + +## [1.0.0] + +### Added + +- Initial release + - As a result of converting our shared controllers repo into a monorepo ([#831](https://github.com/MetaMask/core/pull/831)), we've created this package from select parts of [`@metamask/controllers` v33.0.0](https://github.com/MetaMask/core/tree/v33.0.0), namely: + - `src/third-party/EnsController.ts` + - `src/third-party/EnsController.test.ts` + + All changes listed after this point were applied to this package following the monorepo conversion. + +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.6...HEAD +[19.1.6]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.5...@metamask/ens-controller@19.1.6 +[19.1.5]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.4...@metamask/ens-controller@19.1.5 +[19.1.4]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.3...@metamask/ens-controller@19.1.4 +[19.1.3]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.2...@metamask/ens-controller@19.1.3 +[19.1.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.1...@metamask/ens-controller@19.1.2 +[19.1.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.1.0...@metamask/ens-controller@19.1.1 +[19.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.0.3...@metamask/ens-controller@19.1.0 +[19.0.3]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.0.2...@metamask/ens-controller@19.0.3 +[19.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.0.1...@metamask/ens-controller@19.0.2 +[19.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@19.0.0...@metamask/ens-controller@19.0.1 +[19.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@18.0.0...@metamask/ens-controller@19.0.0 +[18.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@17.1.1...@metamask/ens-controller@18.0.0 +[17.1.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@17.1.0...@metamask/ens-controller@17.1.1 +[17.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@17.0.1...@metamask/ens-controller@17.1.0 +[17.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@17.0.0...@metamask/ens-controller@17.0.1 +[17.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@16.0.0...@metamask/ens-controller@17.0.0 +[16.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.2...@metamask/ens-controller@16.0.0 +[15.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.1...@metamask/ens-controller@15.0.2 +[15.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@15.0.0...@metamask/ens-controller@15.0.1 +[15.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@14.0.1...@metamask/ens-controller@15.0.0 +[14.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@14.0.0...@metamask/ens-controller@14.0.1 +[14.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@13.0.1...@metamask/ens-controller@14.0.0 +[13.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@13.0.0...@metamask/ens-controller@13.0.1 +[13.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@12.0.0...@metamask/ens-controller@13.0.0 +[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@11.0.0...@metamask/ens-controller@12.0.0 +[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@10.0.1...@metamask/ens-controller@11.0.0 +[10.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@10.0.0...@metamask/ens-controller@10.0.1 +[10.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@9.0.0...@metamask/ens-controller@10.0.0 +[9.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@8.0.0...@metamask/ens-controller@9.0.0 +[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@7.0.0...@metamask/ens-controller@8.0.0 +[7.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@6.0.1...@metamask/ens-controller@7.0.0 +[6.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@6.0.0...@metamask/ens-controller@6.0.1 +[6.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.2...@metamask/ens-controller@6.0.0 +[5.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.1...@metamask/ens-controller@5.0.2 +[5.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@5.0.0...@metamask/ens-controller@5.0.1 +[5.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.1.1...@metamask/ens-controller@5.0.0 +[4.1.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.1.0...@metamask/ens-controller@4.1.1 +[4.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@4.0.0...@metamask/ens-controller@4.1.0 +[4.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@3.1.0...@metamask/ens-controller@4.0.0 +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@3.0.0...@metamask/ens-controller@3.1.0 +[3.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@2.0.0...@metamask/ens-controller@3.0.0 +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.2...@metamask/ens-controller@2.0.0 +[1.0.2]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.1...@metamask/ens-controller@1.0.2 +[1.0.1]: https://github.com/MetaMask/core/compare/@metamask/ens-controller@1.0.0...@metamask/ens-controller@1.0.1 +[1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/ens-controller@1.0.0 diff --git a/packages/ens-controller/LICENSE b/packages/ens-controller/LICENSE new file mode 100644 index 00000000000..bbed2e24b91 --- /dev/null +++ b/packages/ens-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2018 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/ens-controller/README.md b/packages/ens-controller/README.md new file mode 100644 index 00000000000..605c10f3c8f --- /dev/null +++ b/packages/ens-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/ens-controller` + +Maps ENS names to their resolved addresses by chain id. + +## Installation + +`yarn add @metamask/ens-controller` + +or + +`npm install @metamask/ens-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/ens-controller/jest.config.js b/packages/ens-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/ens-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/ens-controller/package.json b/packages/ens-controller/package.json new file mode 100644 index 00000000000..fd1d17314d1 --- /dev/null +++ b/packages/ens-controller/package.json @@ -0,0 +1,81 @@ +{ + "name": "@metamask/ens-controller", + "version": "19.1.6", + "description": "Maps ENS names to their resolved addresses by chain id", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/ens-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "MIT", + "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/ens-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ens-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --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/providers": "^5.7.0", + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/messenger": "^2.0.0", + "@metamask/network-controller": "^35.0.1", + "@metamask/utils": "^11.11.0", + "punycode": "^2.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^30.0.0", + "deepmerge": "^4.2.2", + "jest": "^30.4.2", + "ts-jest": "^29.4.11", + "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/ens-controller/src/EnsController-method-action-types.ts b/packages/ens-controller/src/EnsController-method-action-types.ts new file mode 100644 index 00000000000..5b355c8d8d6 --- /dev/null +++ b/packages/ens-controller/src/EnsController-method-action-types.ts @@ -0,0 +1,83 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { EnsController } from './EnsController.js'; + +/** + * Clears ensResolutionsByAddress state property. + */ +export type EnsControllerResetStateAction = { + type: `EnsController:resetState`; + handler: EnsController['resetState']; +}; + +/** + * Remove all chain Ids and ENS entries from state. + */ +export type EnsControllerClearAction = { + type: `EnsController:clear`; + handler: EnsController['clear']; +}; + +/** + * Delete an ENS entry. + * + * @param chainId - Parent chain of the ENS entry to delete. + * @param ensName - Name of the ENS entry to delete. + * @returns Boolean indicating if the entry was deleted. + */ +export type EnsControllerDeleteAction = { + type: `EnsController:delete`; + handler: EnsController['delete']; +}; + +/** + * Retrieve a DNS entry. + * + * @param chainId - Parent chain of the ENS entry to retrieve. + * @param ensName - Name of the ENS entry to retrieve. + * @returns The EnsEntry or null if it does not exist. + */ +export type EnsControllerGetAction = { + type: `EnsController:get`; + handler: EnsController['get']; +}; + +/** + * Add or update an ENS entry by chainId and ensName. + * + * A null address indicates that the ENS name does not resolve. + * + * @param chainId - Id of the associated chain. + * @param ensName - The ENS name. + * @param address - Associated address (or null) to add or update. + * @returns Boolean indicating if the entry was set. + */ +export type EnsControllerSetAction = { + type: `EnsController:set`; + handler: EnsController['set']; +}; + +/** + * Resolve ens by address. + * + * @param nonChecksummedAddress - address + * @returns ens resolution + */ +export type EnsControllerReverseResolveAddressAction = { + type: `EnsController:reverseResolveAddress`; + handler: EnsController['reverseResolveAddress']; +}; + +/** + * Union of all EnsController action types. + */ +export type EnsControllerMethodActions = + | EnsControllerResetStateAction + | EnsControllerClearAction + | EnsControllerDeleteAction + | EnsControllerGetAction + | EnsControllerSetAction + | EnsControllerReverseResolveAddressAction; diff --git a/packages/ens-controller/src/EnsController.test.ts b/packages/ens-controller/src/EnsController.test.ts new file mode 100644 index 00000000000..7a93e4cf314 --- /dev/null +++ b/packages/ens-controller/src/EnsController.test.ts @@ -0,0 +1,942 @@ +import * as providersModule from '@ethersproject/providers'; +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { + toChecksumHexAddress, + toHex, + InfuraNetworkType, +} from '@metamask/controller-utils'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; +import { getDefaultNetworkControllerState } from '@metamask/network-controller'; +import type { + NetworkController, + NetworkState, +} from '@metamask/network-controller'; + +import { + buildMockGetNetworkClientById, + buildCustomNetworkClientConfiguration, +} from '../../network-controller/tests/helpers.js'; +import { EnsController, DEFAULT_ENS_NETWORK_MAP } from './EnsController.js'; +import type { + EnsControllerState, + EnsControllerMessenger, +} from './EnsController.js'; + +const defaultState: EnsControllerState = { + ensEntries: {}, + ensResolutionsByAddress: {}, +}; + +for (const [cid, address] of Object.entries(DEFAULT_ENS_NETWORK_MAP)) { + const chainId = toHex(cid); + defaultState.ensEntries[chainId] = { + '.': { + ensName: '.', + address, + chainId, + }, + }; +} +Object.freeze(defaultState); + +jest.mock('@ethersproject/providers', () => { + const originalModule = jest.requireActual('@ethersproject/providers'); + + return { + __esModule: true, + ...originalModule, + }; +}); + +type AllEnsControllerActions = MessengerActions; + +type AllEnsControllerEvents = MessengerEvents; + +type RootMessenger = Messenger< + MockAnyNamespace, + AllEnsControllerActions, + AllEnsControllerEvents +>; + +const ZERO_X_ERROR_ADDRESS = '0x'; + +const address1 = '0x32Be343B94f860124dC4fEe278FDCBD38C102D88'; +const address2 = '0xc38bf1ad06ef69f0c04e29dbeb4152b4175f0a8d'; +const address3 = '0x89d24A6b4CcB1B6fAA2625fE562bDD9a23260359'; +const name1 = 'foobarb.eth'; +const name2 = 'bazbarb.eth'; + +const address1Checksum = toChecksumHexAddress(address1); +const address2Checksum = toChecksumHexAddress(address2); +const address3Checksum = toChecksumHexAddress(address3); + +const name = 'EnsController'; + +/** + * Constructs the root messenger. + * + * @returns A root messenger. + */ +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +/** + * Constructs the messenger for EnsController actions and events. + * + * @param rootMessenger - The root messenger to base the controller messenger + * off of. + * @param getNetworkClientByIdMock - Optional mock version of `getNetworkClientById`. + * @returns A controller messenger for EnsController. + */ +function getEnsControllerMessenger( + rootMessenger: RootMessenger, + getNetworkClientByIdMock?: NetworkController['getNetworkClientById'], +): EnsControllerMessenger { + const mockNetworkState = jest.fn().mockReturnValue({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + + rootMessenger.registerActionHandler( + 'NetworkController:getState', + mockNetworkState, + ); + + if (!getNetworkClientByIdMock) { + getNetworkClientByIdMock = buildMockGetNetworkClientById(); + } + rootMessenger.registerActionHandler( + 'NetworkController:getNetworkClientById', + getNetworkClientByIdMock, + ); + + const ensControllerMessenger = new Messenger< + 'EnsController', + AllEnsControllerActions, + AllEnsControllerEvents, + RootMessenger + >({ + namespace: name, + parent: rootMessenger, + }); + rootMessenger.delegate({ + messenger: ensControllerMessenger, + actions: [ + 'NetworkController:getNetworkClientById', + 'NetworkController:getState', + ], + }); + return ensControllerMessenger; +} + +/** + * Creates a mock provider. + * + * @returns mock provider + */ +function getProvider() { + return () => Promise.resolve(null); +} + +describe('EnsController', () => { + it('should set default state', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.state).toStrictEqual(defaultState); + }); + + it('should return registry address for `.`', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.get('0x1', '.')).toStrictEqual({ + ensName: '.', + address: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + chainId: '0x1', + }); + }); + + it('should not return registry address for unrecognized chains', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.get('0x666', '.')).toBeNull(); + }); + + it('should add a new ENS entry and return true', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should clear ensResolutionsByAddress state propery when resetState is called', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + state: { + ensResolutionsByAddress: { + [address1Checksum]: 'peaksignal.eth', + }, + }, + }); + + expect(controller.state.ensResolutionsByAddress[address1Checksum]).toBe( + 'peaksignal.eth', + ); + + controller.resetState(); + + expect(controller.state.ensResolutionsByAddress).toStrictEqual({}); + }); + + it('should clear ensResolutionsByAddress state propery on networkDidChange', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + state: { + ensResolutionsByAddress: { + [address1Checksum]: 'peaksignal.eth', + }, + }, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(controller.state.ensResolutionsByAddress).toStrictEqual({}); + }); + + it('should add a new ENS entry with null address and return true', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, null)).toBe(true); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: null, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should update an ENS entry and return true', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name1, address2)).toBe(true); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: address2Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should update an ENS entry with null address and return true', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name1, null)).toBe(true); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: null, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should not update an ENS entry if the address is the same (valid address) and return false', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name1, address1)).toBe(false); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should not update an ENS entry if the address is the same (null) and return false', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, null)).toBe(true); + expect(controller.set('0x1', name1, null)).toBe(false); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: null, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should add multiple ENS entries and update without side effects', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name2, address2)).toBe(true); + expect(controller.set(toHex(2), name1, address1)).toBe(true); + expect(controller.set('0x1', name1, address3)).toBe(true); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: address3Checksum, + chainId: '0x1', + ensName: name1, + }); + expect(controller.state.ensEntries['0x1'][name2]).toStrictEqual({ + address: address2Checksum, + chainId: '0x1', + ensName: name2, + }); + expect(controller.state.ensEntries['0x2'][name1]).toStrictEqual({ + address: address1Checksum, + chainId: toHex(2), + ensName: name1, + }); + }); + + it('should get ENS default registry by chainId when asking for `.`', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.get('0x1', name1)).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should get ENS entry by chainId and ensName', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.get('0x1', name1)).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should return null when getting nonexistent name', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.get('0x1', name2)).toBeNull(); + }); + + it('should return null when getting nonexistent chainId', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.get(toHex(2), name1)).toBeNull(); + }); + + it('should throw on attempt to set invalid ENS entry: chainId', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(() => { + // @ts-expect-error Intentionally invalid chain ID + controller.set('a', name1, address1); + }).toThrow( + 'Invalid ENS entry: { chainId:a, ensName:foobarb.eth, address:0x32Be343B94f860124dC4fEe278FDCBD38C102D88}', + ); + expect(controller.state).toStrictEqual(defaultState); + }); + + it('should throw on attempt to set invalid ENS entry: ENS name', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(() => { + controller.set('0x1', 'fo.eth', address1); + }).toThrow('Invalid ENS name: fo.eth'); + expect(controller.state).toStrictEqual(defaultState); + }); + + it('should allow 3 character ENS names', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + + expect(controller.set('0x1', 'foo.eth', address1)).toBe(true); + expect(controller.state.ensEntries['0x1']['foo.eth']).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: 'foo.eth', + }); + }); + + it('should throw on attempt to set invalid ENS entry: address', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(() => { + controller.set('0x1', name1, 'foo'); + }).toThrow( + 'Invalid ENS entry: { chainId:0x1, ensName:foobarb.eth, address:foo}', + ); + expect(controller.state).toStrictEqual(defaultState); + }); + + it('should remove an ENS entry and return true', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.delete('0x1', name1)).toBe(true); + expect(controller.state).toStrictEqual(defaultState); + }); + + it('should remove chain entries completely when all entries are removed', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.delete('0x1', '.')).toBe(true); + expect(controller.state.ensEntries['0x1'][name1].address).toBe( + address1Checksum, + ); + expect(controller.delete('0x1', name1)).toBe(true); + expect(controller.state.ensEntries['0x1']).toBeUndefined(); + }); + + it('should return false if an ENS entry was NOT deleted due to unsafe input', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + // @ts-expect-error Suppressing error to test runtime behavior + expect(controller.delete('__proto__', 'bar')).toBe(false); + expect(controller.delete(toHex(2), 'constructor')).toBe(false); + }); + + it('should return false if an ENS entry was NOT deleted', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + controller.set('0x1', name1, address1); + expect(controller.delete('0x1', 'bar')).toBe(false); + expect(controller.delete(toHex(2), 'bar')).toBe(false); + expect(controller.state.ensEntries['0x1'][name1]).toStrictEqual({ + address: address1Checksum, + chainId: '0x1', + ensName: name1, + }); + }); + + it('should add multiple ENS entries and remove without side effects', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name2, address2)).toBe(true); + expect(controller.set(toHex(2), name1, address1)).toBe(true); + expect(controller.delete('0x1', name1)).toBe(true); + expect(controller.state.ensEntries['0x1'][name2]).toStrictEqual({ + address: address2Checksum, + chainId: '0x1', + ensName: name2, + }); + expect(controller.state.ensEntries['0x2'][name1]).toStrictEqual({ + address: address1Checksum, + chainId: toHex(2), + ensName: name1, + }); + }); + + it('should clear all ENS entries', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(controller.set('0x1', name1, address1)).toBe(true); + expect(controller.set('0x1', name2, address2)).toBe(true); + expect(controller.set(toHex(2), name1, address1)).toBe(true); + controller.clear(); + expect(controller.state).toStrictEqual({ + ensEntries: {}, + ensResolutionsByAddress: {}, + }); + }); + + describe('reverseResolveName', () => { + it('should return undefined when eth provider is not defined', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ens = new EnsController({ + messenger: ensControllerMessenger, + }); + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should return undefined when network is loading', async function () { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should return undefined when network is not ens supported', async function () { + const rootMessenger = getRootMessenger(); + const getNetworkClientById = buildMockGetNetworkClientById({ + 'AAAA-AAAA-AAAA-AAAA': buildCustomNetworkClientConfiguration({ + chainId: '0x9999999', + }), + }); + const ensControllerMessenger = getEnsControllerMessenger( + rootMessenger, + getNetworkClientById, + ); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: 'AAAA-AAAA-AAAA-AAAA', + }); + }, + }); + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should only resolve an ENS name once', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(address1); + jest + .spyOn(ethProvider, 'lookupAddress') + .mockResolvedValue('peaksignal.eth'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBe('peaksignal.eth'); + expect(await ens.reverseResolveAddress(address1)).toBe('peaksignal.eth'); + }); + + it('should fail if lookupAddress through an error', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest.spyOn(ethProvider, 'lookupAddress').mockRejectedValue('error'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should fail if lookupAddress returns a null value', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest.spyOn(ethProvider, 'lookupAddress').mockResolvedValue(null); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should fail if resolveName through an error', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest + .spyOn(ethProvider, 'lookupAddress') + .mockResolvedValue('peaksignal.eth'); + jest.spyOn(ethProvider, 'resolveName').mockRejectedValue('error'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should fail if resolveName returns a null value', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(null); + jest + .spyOn(ethProvider, 'lookupAddress') + .mockResolvedValue('peaksignal.eth'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should fail if registred address is zero x error address', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest + .spyOn(ethProvider, 'resolveName') + .mockResolvedValue(ZERO_X_ERROR_ADDRESS); + jest + .spyOn(ethProvider, 'lookupAddress') + .mockResolvedValue('peaksignal.eth'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + + it('should fail if the name is registered to a different address than the reverse resolved', async () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + + const ethProvider = new providersModule.Web3Provider(getProvider()); + jest.spyOn(ethProvider, 'resolveName').mockResolvedValue(address2); + jest + .spyOn(ethProvider, 'lookupAddress') + .mockResolvedValue('peaksignal.eth'); + jest.spyOn(providersModule, 'Web3Provider').mockReturnValue(ethProvider); + const ens = new EnsController({ + messenger: ensControllerMessenger, + onNetworkDidChange: (listener) => { + listener({ + ...getDefaultNetworkControllerState(), + selectedNetworkClientId: InfuraNetworkType.mainnet, + }); + }, + }); + + expect(await ens.reverseResolveAddress(address1)).toBeUndefined(); + }); + }); + + describe('metadata', () => { + it('includes expected state in debug snapshots', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(`{}`); + }); + + it('includes expected state in state logs', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "ensEntries": { + "0x1": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x1", + "ensName": ".", + }, + }, + "0x3": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x3", + "ensName": ".", + }, + }, + "0x4": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4", + "ensName": ".", + }, + }, + "0x4268": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4268", + "ensName": ".", + }, + }, + "0x5": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x5", + "ensName": ".", + }, + }, + "0xaa36a7": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0xaa36a7", + "ensName": ".", + }, + }, + }, + "ensResolutionsByAddress": {}, + } + `); + }); + + it('persists expected state', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "ensEntries": { + "0x1": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x1", + "ensName": ".", + }, + }, + "0x3": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x3", + "ensName": ".", + }, + }, + "0x4": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4", + "ensName": ".", + }, + }, + "0x4268": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4268", + "ensName": ".", + }, + }, + "0x5": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x5", + "ensName": ".", + }, + }, + "0xaa36a7": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0xaa36a7", + "ensName": ".", + }, + }, + }, + "ensResolutionsByAddress": {}, + } + `); + }); + + it('exposes expected state to UI', () => { + const rootMessenger = getRootMessenger(); + const ensControllerMessenger = getEnsControllerMessenger(rootMessenger); + const controller = new EnsController({ + messenger: ensControllerMessenger, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "ensEntries": { + "0x1": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x1", + "ensName": ".", + }, + }, + "0x3": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x3", + "ensName": ".", + }, + }, + "0x4": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4", + "ensName": ".", + }, + }, + "0x4268": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x4268", + "ensName": ".", + }, + }, + "0x5": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0x5", + "ensName": ".", + }, + }, + "0xaa36a7": { + ".": { + "address": "0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e", + "chainId": "0xaa36a7", + "ensName": ".", + }, + }, + }, + "ensResolutionsByAddress": {}, + } + `); + }); + }); +}); diff --git a/packages/ens-controller/src/EnsController.ts b/packages/ens-controller/src/EnsController.ts new file mode 100644 index 00000000000..456178f4dad --- /dev/null +++ b/packages/ens-controller/src/EnsController.ts @@ -0,0 +1,420 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { BaseController } from '@metamask/base-controller'; +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import type { ChainId } from '@metamask/controller-utils'; +import { + normalizeEnsName, + isValidHexAddress, + isSafeDynamicKey, + toChecksumHexAddress, + CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP, + convertHexToDecimal, + toHex, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { + NetworkControllerGetNetworkClientByIdAction, + NetworkControllerGetStateAction, + NetworkState, +} from '@metamask/network-controller'; +import type { Hex } from '@metamask/utils'; +import { createProjectLogger } from '@metamask/utils'; +import { toASCII } from 'punycode/punycode.js'; + +import type { EnsControllerMethodActions } from './EnsController-method-action-types.js'; + +const log = createProjectLogger('ens-controller'); + +const name = 'EnsController'; + +const MESSENGER_EXPOSED_METHODS = [ + 'clear', + 'delete', + 'get', + 'resetState', + 'reverseResolveAddress', + 'set', +] as const; + +// Map of chainIDs and ENS registry contract addresses +export const DEFAULT_ENS_NETWORK_MAP: Record = { + // Mainnet + 1: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + // Ropsten + 3: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + // Rinkeby + 4: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + // Goerli + 5: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + // Holesky + 17000: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', + // Sepolia + 11155111: '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e', +}; + +/** + * @type EnsEntry + * + * ENS entry representation + * + * @property chainId - Id of the associated chain + * @property ensName - The ENS name + * @property address - Hex address with the ENS name, or null + */ +export type EnsEntry = { + chainId: Hex; + ensName: string; + address: string | null; +}; + +/** + * @type EnsControllerState + * + * ENS controller state + * + * @property ensEntries - Object of ENS entry objects + */ +export type EnsControllerState = { + ensEntries: { + [chainId: Hex]: { + [ensName: string]: EnsEntry; + }; + }; + ensResolutionsByAddress: { [key: string]: string }; +}; + +export type EnsControllerGetStateAction = ControllerGetStateAction< + typeof name, + EnsControllerState +>; + +export type EnsControllerActions = + | EnsControllerGetStateAction + | EnsControllerMethodActions; + +export type EnsControllerEvents = ControllerStateChangeEvent< + typeof name, + EnsControllerState +>; + +export type AllowedActions = + | NetworkControllerGetNetworkClientByIdAction + | NetworkControllerGetStateAction; + +export type EnsControllerMessenger = Messenger< + typeof name, + EnsControllerActions | AllowedActions, + EnsControllerEvents +>; + +const metadata: StateMetadata = { + ensEntries: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + ensResolutionsByAddress: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +}; + +const defaultState = { + ensEntries: {}, + ensResolutionsByAddress: {}, +}; + +const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'; +const ZERO_X_ERROR_ADDRESS = '0x'; + +/** + * Controller that manages a list ENS names and their resolved addresses + * by chainId. A null address indicates an unresolved ENS name. + */ +export class EnsController extends BaseController< + typeof name, + EnsControllerState, + EnsControllerMessenger +> { + #ethProvider: Web3Provider | null = null; + + /** + * Creates an EnsController instance. + * + * @param options - Constructor options. + * @param options.registriesByChainId - Map between chain IDs and ENS contract addresses. + * @param options.messenger - A reference to the messaging system. + * @param options.state - Initial state to set on this controller. + * @param options.onNetworkDidChange - Allows subscribing to network controller networkDidChange events. + */ + constructor({ + registriesByChainId = DEFAULT_ENS_NETWORK_MAP, + messenger, + state = {}, + onNetworkDidChange, + }: { + registriesByChainId?: Record; + messenger: EnsControllerMessenger; + state?: Partial; + onNetworkDidChange?: ( + listener: (networkState: NetworkState) => void, + ) => void; + }) { + super({ + name, + metadata, + messenger, + state: { + ...defaultState, + ensEntries: Object.fromEntries( + Object.entries(registriesByChainId).map(([chainId, address]) => [ + toHex(chainId), + { + '.': { + address, + chainId: toHex(chainId), + ensName: '.', + }, + }, + ]), + ), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.#setDefaultEthProvider(registriesByChainId); + + if (onNetworkDidChange) { + onNetworkDidChange(({ selectedNetworkClientId }) => { + this.resetState(); + this.#setEthProvider(selectedNetworkClientId, registriesByChainId); + }); + } + } + + /** + * Clears ensResolutionsByAddress state property. + */ + resetState() { + this.update((currentState) => { + currentState.ensResolutionsByAddress = {}; + }); + } + + /** + * Remove all chain Ids and ENS entries from state. + */ + clear() { + this.update((state) => { + state.ensEntries = {}; + }); + } + + /** + * Delete an ENS entry. + * + * @param chainId - Parent chain of the ENS entry to delete. + * @param ensName - Name of the ENS entry to delete. + * @returns Boolean indicating if the entry was deleted. + */ + delete(chainId: Hex, ensName: string): boolean { + const normalizedEnsName = normalizeEnsName(ensName); + if ( + !isSafeDynamicKey(chainId) || + !normalizedEnsName || + !this.state.ensEntries[chainId]?.[normalizedEnsName] + ) { + return false; + } + + this.update((state) => { + delete state.ensEntries[chainId][normalizedEnsName]; + + if (Object.keys(state.ensEntries[chainId]).length === 0) { + delete state.ensEntries[chainId]; + } + }); + return true; + } + + /** + * Retrieve a DNS entry. + * + * @param chainId - Parent chain of the ENS entry to retrieve. + * @param ensName - Name of the ENS entry to retrieve. + * @returns The EnsEntry or null if it does not exist. + */ + get(chainId: Hex, ensName: string): EnsEntry | null { + const normalizedEnsName = normalizeEnsName(ensName); + + // TODO Explicitly handle the case where `normalizedEnsName` is `null` + // eslint-disable-next-line no-implicit-coercion + return !!normalizedEnsName && this.state.ensEntries[chainId] + ? this.state.ensEntries[chainId][normalizedEnsName] || null + : null; + } + + /** + * Add or update an ENS entry by chainId and ensName. + * + * A null address indicates that the ENS name does not resolve. + * + * @param chainId - Id of the associated chain. + * @param ensName - The ENS name. + * @param address - Associated address (or null) to add or update. + * @returns Boolean indicating if the entry was set. + */ + set(chainId: Hex, ensName: string, address: string | null): boolean { + if ( + !Number.isInteger(Number.parseInt(chainId, 10)) || + !ensName || + typeof ensName !== 'string' || + (address && !isValidHexAddress(address)) + ) { + throw new Error( + `Invalid ENS entry: { chainId:${chainId}, ensName:${ensName}, address:${address}}`, + ); + } + + const normalizedEnsName = normalizeEnsName(ensName); + if (!normalizedEnsName) { + throw new Error(`Invalid ENS name: ${ensName}`); + } + + const normalizedAddress = address ? toChecksumHexAddress(address) : null; + const subState = this.state.ensEntries[chainId]; + + if (subState?.[normalizedEnsName]?.address === normalizedAddress) { + return false; + } + + this.update((state) => { + state.ensEntries = { + ...this.state.ensEntries, + [chainId]: { + ...this.state.ensEntries[chainId], + [normalizedEnsName]: { + address: normalizedAddress, + chainId, + ensName: normalizedEnsName, + }, + }, + }; + }); + return true; + } + + #setDefaultEthProvider(registriesByChainId?: Record) { + const { selectedNetworkClientId } = this.messenger.call( + 'NetworkController:getState', + ); + this.#setEthProvider(selectedNetworkClientId, registriesByChainId); + } + + #setEthProvider( + selectedNetworkClientId: string, + registriesByChainId?: Record, + ) { + const { + configuration: { chainId: currentChainId }, + provider, + } = this.messenger.call( + 'NetworkController:getNetworkClientById', + selectedNetworkClientId, + ); + + if ( + registriesByChainId?.[parseInt(currentChainId, 16)] && + this.#getChainEnsSupport(currentChainId) + ) { + this.#ethProvider = new Web3Provider(provider, { + chainId: convertHexToDecimal(currentChainId), + name: CHAIN_ID_TO_ETHERS_NETWORK_NAME_MAP[currentChainId as ChainId], + ensAddress: registriesByChainId[parseInt(currentChainId, 16)], + }); + } else { + this.#ethProvider = null; + } + } + + /** + * Check if the chain supports ENS. + * + * @param chainId - chain id. + * @returns Boolean indicating if the chain supports ENS. + */ + #getChainEnsSupport(chainId: Hex) { + return Boolean(this.state.ensEntries[chainId]); + } + + /** + * Resolve ens by address. + * + * @param nonChecksummedAddress - address + * @returns ens resolution + */ + async reverseResolveAddress(nonChecksummedAddress: string) { + if (!this.#ethProvider) { + return undefined; + } + + const address = toChecksumHexAddress(nonChecksummedAddress); + if (this.state.ensResolutionsByAddress[address]) { + return this.state.ensResolutionsByAddress[address]; + } + + let domain: string | null; + try { + domain = await this.#ethProvider.lookupAddress(address); + } catch (error) { + log(error); + return undefined; + } + + if (!domain) { + return undefined; + } + + let registeredAddress: string | null; + try { + registeredAddress = await this.#ethProvider.resolveName(domain); + } catch (error) { + log(error); + return undefined; + } + + if (!registeredAddress) { + return undefined; + } + + if ( + registeredAddress === ZERO_ADDRESS || + registeredAddress === ZERO_X_ERROR_ADDRESS + ) { + return undefined; + } + if (toChecksumHexAddress(registeredAddress) !== address) { + return undefined; + } + + this.update((state) => { + state.ensResolutionsByAddress[address] = toASCII(domain as string); + }); + + return domain; + } +} + +export default EnsController; diff --git a/packages/ens-controller/src/index.ts b/packages/ens-controller/src/index.ts new file mode 100644 index 00000000000..9b6610d6d88 --- /dev/null +++ b/packages/ens-controller/src/index.ts @@ -0,0 +1,9 @@ +export * from './EnsController.js'; +export type { + EnsControllerResetStateAction, + EnsControllerClearAction, + EnsControllerDeleteAction, + EnsControllerGetAction, + EnsControllerSetAction, + EnsControllerReverseResolveAddressAction, +} from './EnsController-method-action-types.js'; diff --git a/packages/ens-controller/tsconfig.build.json b/packages/ens-controller/tsconfig.build.json new file mode 100644 index 00000000000..c55f67af5af --- /dev/null +++ b/packages/ens-controller/tsconfig.build.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/ens-controller/tsconfig.json b/packages/ens-controller/tsconfig.json new file mode 100644 index 00000000000..c6a3a4c830a --- /dev/null +++ b/packages/ens-controller/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../network-controller" }, + { "path": "../messenger" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/ens-controller/typedoc.json b/packages/ens-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/ens-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 2c1f42780cc..27ceff651a6 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -507,7 +507,7 @@ graph TB direction TB subgraph engine["Engine wiring"] CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] - SInit["kyc-service-init.ts
new KycService({ env, messenger, baseUrl })"] + SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index e4c43a53aed..34e465f3d5a 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,8 +9,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of the `@metamask/kyc-controller` package: a platform-agnostic controller and data service for orchestrating KYC / identity verification across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615), [#9712](https://github.com/MetaMask/core/pull/9712)) - - `KycController` owns the end-to-end flow: the KYC state machine, the MoonPay Check/Auth hosted-frame message protocol, X25519 credential decryption, and SumSub orchestration through an injected `KycSumSubLauncher` adapter (keeping the controller SDK-free). It performs the authenticated Universal KYC (UKYC) HTTP calls (disclaimers, sessions, kyc-required, wrapping-key, JWKS, UKYC session/journey/status polling) with `superstruct` response validation and `createServicePolicy` resilience, sourcing the bearer token and geolocation through the messenger. - - Passing an optional `product` (`ramps` or `card`) makes the controller automatically run the KYC-required check and chain into the SumSub sub-flow after authentication, with generation/phase guards so `reset()` and stale frame messages cannot corrupt state. +- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615), [#9853](https://github.com/MetaMask/core/pull/9853)) + - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. + - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. + - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. + - Add automatic post-authentication continuation to `KycController` + - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API + - Add UKYC session-status polling to `KycController` + - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor + +### Removed + +- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. ([#9853](https://github.com/MetaMask/core/pull/9853)) + +### Fixed + +- Clear `moonpayCustomerId` when the active vendor changes, so `getCustomerIdentity()` can no longer report a MoonPay customer id under another vendor. The id is dropped when `initialize` starts a non-MoonPay flow and when `createIronCustomer` switches to Iron. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Call `unref()` on the user-status poll timer only when it exists. React Native and browser timers are numbers, so the unconditional call threw when Money status polling started outside Node. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Skip the `session_not_in_valid_state` completion write when a `reset()` superseded the SumSub flow, so a late vendor response can no longer force `userStatus` to `completed` (and publish `statusChanged`) on an idle controller. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index ffa8371034a..5b0c23f7fff 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -69,7 +69,7 @@ "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", - "@tanstack/query-core": "^5.62.16", + "@tanstack/query-core": "^4.43.0", "reselect": "^5.1.1", "tweetnacl": "^1.0.3" }, diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 2586ea9c637..b2aa85cca42 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -16,12 +16,27 @@ import type { KycController } from './KycController.js'; * authentication completes (and chains into document verification when KYC * is required). When omitted, the flow stops at `form` and the consumer must * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Pass `iron` for the + * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`. */ export type KycControllerInitializeAction = { type: `KycController:initialize`; handler: KycController['initialize']; }; +/** + * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can + * ensure the customer exists before showing T&C screens independently of + * {@link initialize}. + * + * @param params - The parameters. + * @param params.email - Email for the Iron customer. + */ +export type KycControllerCreateIronCustomerAction = { + type: `KycController:createIronCustomer`; + handler: KycController['createIronCustomer']; +}; + /** * Loads the disclaimers for the resolved (or provided) country. * @@ -42,6 +57,10 @@ export type KycControllerLoadDisclaimersAction = { * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. + * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were + * accepted (T&C2). Defaults to `true` when omitted. + * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted + * (T&C2). Defaults to `true` when omitted. */ export type KycControllerAcceptTermsAndStartSessionAction = { type: `KycController:acceptTermsAndStartSession`; @@ -126,6 +145,23 @@ export type KycControllerGetKycStatusAction = { handler: KycController['getKycStatus']; }; +/** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}). + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ +export type KycControllerGetCustomerIdentityAction = { + type: `KycController:getCustomerIdentity`; + handler: KycController['getCustomerIdentity']; +}; + /** * Runs the SumSub document-verification sub-flow end to end: * @@ -154,6 +190,18 @@ export type KycControllerStartSumSubAction = { handler: KycController['startSumSub']; }; +/** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ +export type KycControllerRefreshKycStatusAction = { + type: `KycController:refreshKycStatus`; + handler: KycController['refreshKycStatus']; +}; + /** * Fetches the current UKYC session status for the active sub-flow and records * it on state. Useful for a one-off refresh outside the automatic polling @@ -181,6 +229,7 @@ export type KycControllerResetAction = { */ export type KycControllerMethodActions = | KycControllerInitializeAction + | KycControllerCreateIronCustomerAction | KycControllerLoadDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction | KycControllerClearSavedTermsAction @@ -190,6 +239,8 @@ export type KycControllerMethodActions = | KycControllerBuildResetFrameUrlAction | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction + | KycControllerGetCustomerIdentityAction | KycControllerStartSumSubAction + | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3c3078c27e3..c34d6a88eed 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1215,6 +1215,91 @@ describe('KycController', () => { }); }); + describe('getCustomerIdentity', () => { + it('returns null before a vendor customer id is captured', async () => { + await withController(({ controller }) => { + expect(controller.getCustomerIdentity()).toBeNull(); + }); + }); + + it('returns the vendor-scoped identity once captured', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + expect(controller.getCustomerIdentity()).toStrictEqual({ + vendor: 'moonpay', + id: 'cust-1', + }); + }, + ); + }); + + it('returns null after reset clears the captured id', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('drops a MoonPay id when initialize switches to another vendor', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('keeps a MoonPay id when initialize stays on MoonPay', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('drops a MoonPay id when an Iron customer is created', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.createIronCustomer({ email: 'a@b.co' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + }); + describe('startSumSub', () => { it('throws and marks failed when the SDK is unavailable', async () => { await withController(async ({ controller, launcher }) => { @@ -1769,6 +1854,694 @@ describe('KycController', () => { }); }); + describe('iron vendor flow', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('creates an Iron customer and loads Iron disclaimers on initialize', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchIronDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'Iron T&C', url: 'https://t' }, + ]); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.createIronCustomer).toHaveBeenCalledWith({ + email: 'a@b.co', + }); + expect(handlers.fetchIronDisclaimers).toHaveBeenCalledWith({ + country: 'USA', + }); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.activeProduct).toBe('money'); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimers).toHaveLength(1); + }); + }); + + it('fails initialize when Iron customer creation fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createIronCustomer.mockRejectedValue(new Error('iron down')); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch( + /Iron customer creation failed/u, + ); + }); + }); + + it('does not fail initialize when reset lands during Iron customer creation', async () => { + await withController(async ({ controller, handlers }) => { + let release: (value: { + id: string; + email: string; + status: string; + }) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + }); + controller.reset(); + release({ id: '1', email: 'a@b.co', status: 'SigningsRequired' }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('does not fail initialize when Iron customer creation rejects after reset', async () => { + await withController(async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + }); + controller.reset(); + release(new Error('late')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('resumes an Iron session when terms and email are already present', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['d1'], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.submitConsents).toHaveBeenCalled(); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('done'); + controller.reset(); + }, + ); + }); + + it('createIronCustomer sets the vendor and fails on API errors', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createIronCustomer.mockRejectedValue(new Error('nope')); + + await controller.createIronCustomer({ email: 'a@b.co' }); + + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.email).toBe('a@b.co'); + expect(controller.state.phase).toBe('error'); + }); + }); + + it('createIronCustomer ignores API errors after reset', async () => { + await withController(async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.createIronCustomer({ email: 'a@b.co' }); + controller.reset(); + release(new Error('late')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('posts consents and starts SumSub without MoonPay frames', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.submitConsents.mockResolvedValue(undefined); + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.submitConsents).toHaveBeenCalledWith({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }); + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ vendorId: 'iron' }), + ); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + controller.reset(); + }, + ); + }); + + it('fails the Iron session when email is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails the Iron session when disclaimer acceptance is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + email: 'a@b.co', + disclaimers: [], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing Iron disclaimer/u); + }, + ); + }); + + it('returns to terms when SumSub fails during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error('sumsub down'), + ); + handlers.fetchIronDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Iron session failed/u); + }, + ); + }); + + it('keeps done when status refresh fails after a successful SumSub', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + controller.reset(); + }, + ); + }); + + it('ignores in-flight Iron consents after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: () => void = () => { + // placeholder + }; + handlers.submitConsents.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + controller.reset(); + release(); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores SumSub completion after reset during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + let releaseLaunch: (value: { ok: boolean }) => void = () => { + // placeholder + }; + launcher.launch.mockReturnValue( + new Promise((resolve) => { + releaseLaunch = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + // Consents + UKYC session run first; wait until launch is pending. + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + releaseLaunch({ ok: true }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores Iron session failures after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.submitConsents.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + controller.reset(); + release(new Error('late consent failure')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('refreshKycStatus stores status and emits statusChanged', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('KycController:statusChanged', listener); + handlers.fetchKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: 'ss-1', + }); + + const result = await controller.refreshKycStatus(); + + expect(result).toStrictEqual({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + expect(controller.state.userStatus).toBe('completed'); + expect(listener).toHaveBeenCalledWith({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + }, + ); + }); + + it('polls user status while pending and stops on a terminal status', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + expect(controller.state.userStatus).toBe('pending'); + + // First tick stays pending and reschedules; second tick completes. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('pending'); + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('completed'); + + // A second refresh while pending would no-op the timer start; then + // reset clears any leftover handles. + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + await controller.refreshKycStatus(); + await controller.refreshKycStatus(); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status poll ticks after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((resolve) => { + release = resolve; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release({ status: 'completed' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps polling when a user-status tick fails transiently', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status ticks that fail after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release(new Error('late')); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('returns cached user status when reset lands during refresh', async () => { + await withController( + { + options: { + state: { userStatus: 'pending' }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('pending'); + }, + ); + }); + + it('defaults superseded refresh status to not-started when unset', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('not-started'); + }, + ); + }); + + it('maps session_not_in_valid_state to completed during SumSub', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit' }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error( + "Fetching 'https://x' failed with status '409': session_not_in_valid_state", + ), + ); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('leaves an already-reset controller idle when SumSub reports a stale session', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit' }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (error: Error) => void = () => undefined; + handlers.createUkycSession.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.startSumSub(); + controller.reset(); + rejectSession(new Error('session_not_in_valid_state')); + + expect(await pending).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBeNull(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('keeps phase done when Iron SumSub reports already completed', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error('session_not_in_valid_state'), + ); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + }); + }); + describe('messenger actions', () => { it('exposes methods as messenger actions', async () => { await withController(({ rootMessenger }) => { @@ -1791,6 +2564,11 @@ type ServiceHandlers = { fetchDisclaimers: jest.Mock; createSession: jest.Mock; checkKycRequired: jest.Mock; + createIronCustomer: jest.Mock; + fetchIronDisclaimers: jest.Mock; + checkIronKycRequired: jest.Mock; + submitConsents: jest.Mock; + fetchKycStatus: jest.Mock; getWrappingKey: jest.Mock; fetchJwks: jest.Mock; createUkycSession: jest.Mock; @@ -1821,6 +2599,11 @@ const SERVICE_ACTIONS = [ 'KycService:fetchDisclaimers', 'KycService:createSession', 'KycService:checkKycRequired', + 'KycService:createIronCustomer', + 'KycService:fetchIronDisclaimers', + 'KycService:checkIronKycRequired', + 'KycService:submitConsents', + 'KycService:fetchKycStatus', 'KycService:getWrappingKey', 'KycService:fetchJwks', 'KycService:createUkycSession', @@ -1886,6 +2669,15 @@ function withController( fetchDisclaimers: jest.fn().mockResolvedValue([]), createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + createIronCustomer: jest.fn().mockResolvedValue({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }), + fetchIronDisclaimers: jest.fn().mockResolvedValue([]), + checkIronKycRequired: jest.fn().mockResolvedValue({ kycRequired: true }), + submitConsents: jest.fn().mockResolvedValue(undefined), + fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }), getWrappingKey: jest.fn().mockResolvedValue({ id: 'wk', jwtChain: 'jwt.chain.sig', @@ -1920,6 +2712,26 @@ function withController( 'KycService:checkKycRequired', handlers.checkKycRequired, ); + rootMessenger.registerActionHandler( + 'KycService:createIronCustomer', + handlers.createIronCustomer, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchIronDisclaimers', + handlers.fetchIronDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:checkIronKycRequired', + handlers.checkIronKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:submitConsents', + handlers.submitConsents, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchKycStatus', + handlers.fetchKycStatus, + ); rootMessenger.registerActionHandler( 'KycService:getWrappingKey', handlers.getWrappingKey, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 7d6af2b4775..6a4d98520c3 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -18,12 +18,15 @@ import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { + KycCustomerIdentity, KycDisclaimer, KycPhase, KycProduct, KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, + KycUserStatus, + KycVendor, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; @@ -110,6 +113,14 @@ const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ const VENDOR_PROCESSING_MESSAGE = 'Your KYC has been submitted and is being processed by the vendor.'; +// UKYC / relay error indicating the applicant already finished KYC. Mapped to +// the simplified `completed` user status for the Money toast surface. +const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; + +// How often to refresh the user-keyed `GET /kyc/status` while the simplified +// status is still `pending`. Overridable via the constructor. +const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000; + // === STATE === /** @@ -146,6 +157,13 @@ export type KycControllerState = { /** Vendor customer id, used for the SumSub hand-off. */ moonpayCustomerId: string | null; + /** + * The identity vendor driving the current flow. Captured at `initialize`. + * Defaults to `moonpay` when omitted so existing ramps/card callers keep + * the Check/Auth frame path. `iron` skips those frames. + */ + activeVendor: KycVendor; + /** * The product the current flow is running for. Captured at `initialize` * (or `acceptTermsAndStartSession`) and used to automatically run the @@ -160,6 +178,17 @@ export type KycControllerState = { /** ISO-8601 timestamp of the last KYC-required check (persisted). */ lastCheckedAt: string | null; + /** + * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the + * Money toast can render across cold starts). `null` until the first + * successful `refreshKycStatus`. + */ + userStatus: KycUserStatus | null; + /** Optional SumSub session id for the retryable error path. */ + userStatusSumsubSessionId: string | null; + /** Optional machine-readable error code for terminal / EDD UX. */ + userStatusErrorCode: string | null; + /** SumSub document-verification sub-flow state. */ sumsub: { status: KycSumSubStatus; @@ -247,6 +276,12 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, + activeVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, activeProduct: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -265,6 +300,24 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + userStatus: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + userStatusSumsubSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: true, + }, + userStatusErrorCode: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, sumsub: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -292,9 +345,13 @@ export function getDefaultKycControllerState(): KycControllerState { sessionToken: null, accessToken: null, moonpayCustomerId: null, + activeVendor: 'moonpay', activeProduct: null, kycRequiredByProduct: {}, lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, sumsub: { status: 'idle', result: null, @@ -311,6 +368,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'initialize', 'loadDisclaimers', 'acceptTermsAndStartSession', + 'createIronCustomer', 'clearSavedTerms', 'handleFrameMessage', 'buildCheckFrameUrl', @@ -318,6 +376,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'getCustomerIdentity', + 'refreshKycStatus', 'startSumSub', 'getSessionStatus', 'reset', @@ -342,7 +402,23 @@ export type KycControllerStateChangeEvent = ControllerStateChangeEvent< KycControllerState >; -export type KycControllerEvents = KycControllerStateChangeEvent; +/** + * Published when the user-keyed simplified KYC status changes (Money toast). + */ +export type KycControllerStatusChangedEvent = { + type: `${typeof controllerName}:statusChanged`; + payload: [ + { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }, + ]; +}; + +export type KycControllerEvents = + | KycControllerStateChangeEvent + | KycControllerStatusChangedEvent; type AllowedEvents = never; @@ -369,6 +445,12 @@ export type KycControllerOptions = { * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. */ sessionStatusPollIntervalMs?: number; + /** + * How often, in milliseconds, to refresh `GET /kyc/status` while the + * simplified user status is `pending`. Defaults to + * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}. + */ + userStatusPollIntervalMs?: number; }; /** @@ -434,6 +516,15 @@ export class KycController extends BaseController< */ #pollToken = 0; + /** Interval, in milliseconds, between user-keyed status polls. */ + readonly #userStatusPollIntervalMs: number; + + /** Handle for the scheduled next user-status poll, or `null`. */ + #userStatusPollTimer: ReturnType | null = null; + + /** Monotonic token for the user-status poll loop (see `#pollToken`). */ + #userStatusPollToken = 0; + /** * Constructs a new {@link KycController}. * @@ -443,12 +534,15 @@ export class KycController extends BaseController< * @param options.sumsubLauncher - The platform SumSub launcher adapter. * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC * session status after the SumSub SDK completes. + * @param options.userStatusPollIntervalMs - How often to refresh the + * user-keyed KYC status while it is still `pending`. */ constructor({ messenger, state, sumsubLauncher, sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS, }: KycControllerOptions) { super({ messenger, @@ -459,6 +553,7 @@ export class KycController extends BaseController< this.#sumsubLauncher = sumsubLauncher; this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; + this.#userStatusPollIntervalMs = userStatusPollIntervalMs; this.#keypair = generateKeyPair(); this.messenger.registerMethodActionHandlers( @@ -510,10 +605,13 @@ export class KycController extends BaseController< * authentication completes (and chains into document verification when KYC * is required). When omitted, the flow stops at `form` and the consumer must * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Pass `iron` for the + * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`. */ async initialize(params?: { email?: string; product?: KycProduct; + vendor?: KycVendor; }): Promise { // A repeat `initialize` while a session flow is already in progress must // not tear it down: creating a new vendor session clears the tokens and @@ -524,6 +622,8 @@ export class KycController extends BaseController< return; } + const vendor = params?.vendor ?? 'moonpay'; + // `initialize` starts a fresh flow, so `activeProduct` is always reset to // this call's product (or `null`). Otherwise a prior run's product could // linger and cause `#continueAfterAuthentication` to auto-run the check / @@ -532,6 +632,13 @@ export class KycController extends BaseController< if (params?.email) { state.email = params.email; } + state.activeVendor = vendor; + // `moonpayCustomerId` is only ever issued by the MoonPay Check / Auth + // frames. Leaving it set while the flow switches to another vendor would + // make `getCustomerIdentity` report a MoonPay id under the wrong vendor. + if (vendor !== 'moonpay') { + state.moonpayCustomerId = null; + } state.activeProduct = params?.product ?? null; }); @@ -550,12 +657,37 @@ export class KycController extends BaseController< // Ignore; disclaimers loading will surface a country error if needed. } + // Iron: create the empty-shell customer before T&C (offsite decision). + if (vendor === 'iron' && this.state.email) { + try { + await this.messenger.call('KycService:createIronCustomer', { + email: this.state.email, + }); + if (this.#generation !== generation) { + return; + } + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Iron customer creation failed: ${String(error)}`); + return; + } + } + const hasTerms = Boolean(this.state.termsAcceptedAt) && this.state.acceptedDisclaimerIds.length > 0; if (hasTerms && this.state.email) { - await this.#createSession(); + if (vendor === 'iron') { + await this.#startIronSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + } else { + await this.#createSession(); + } return; } @@ -565,6 +697,35 @@ export class KycController extends BaseController< await this.loadDisclaimers(); } + /** + * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can + * ensure the customer exists before showing T&C screens independently of + * {@link initialize}. + * + * @param params - The parameters. + * @param params.email - Email for the Iron customer. + */ + async createIronCustomer(params: { email: string }): Promise { + this.#applyUpdate((state) => { + state.email = params.email; + state.activeVendor = 'iron'; + // See `initialize`: a MoonPay-issued customer id must not survive a + // switch to Iron, or `getCustomerIdentity` reports the wrong vendor. + state.moonpayCustomerId = null; + }); + const generation = this.#generation; + try { + await this.messenger.call('KycService:createIronCustomer', { + email: params.email, + }); + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Iron customer creation failed: ${String(error)}`); + } + } + /** * Loads the disclaimers for the resolved (or provided) country. * @@ -586,10 +747,14 @@ export class KycController extends BaseController< state.geoCountry = country; }); } - const disclaimers = await this.messenger.call( - 'KycService:fetchDisclaimers', - { country }, - ); + const disclaimers = + this.state.activeVendor === 'iron' + ? await this.messenger.call('KycService:fetchIronDisclaimers', { + country, + }) + : await this.messenger.call('KycService:fetchDisclaimers', { + country, + }); this.#updateIfCurrent(generation, (state) => { state.disclaimers = disclaimers; state.disclaimersError = null; @@ -610,10 +775,16 @@ export class KycController extends BaseController< * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. + * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were + * accepted (T&C2). Defaults to `true` when omitted. + * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted + * (T&C2). Defaults to `true` when omitted. */ async acceptTermsAndStartSession(params?: { email?: string; product?: KycProduct; + sumsubTncSigned?: boolean; + idosTncSigned?: boolean; }): Promise { const termsAcceptedAt = new Date().toISOString(); const disclaimerIds = this.state.disclaimers.map( @@ -629,9 +800,100 @@ export class KycController extends BaseController< state.termsAcceptedAt = termsAcceptedAt; state.acceptedDisclaimerIds = disclaimerIds; }); + if (this.state.activeVendor === 'iron') { + await this.#startIronSession({ + sumsubTncSigned: params?.sumsubTncSigned ?? true, + idosTncSigned: params?.idosTncSigned ?? true, + }); + return; + } await this.#createSession(); } + /** + * Iron-only path: post consents (Iron signings + Sumsub/idOS ack), then + * launch SumSub — skipping MoonPay Check/Auth frames. + * + * @param consents - T&C2 boolean flags. + * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.idosTncSigned - Whether idOS T&C were accepted. + */ + async #startIronSession(consents: { + sumsubTncSigned: boolean; + idosTncSigned: boolean; + }): Promise { + const { email, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for Iron session.'); + return; + } + if (acceptedDisclaimerIds.length === 0) { + this.#fail('Missing Iron disclaimer acceptance.'); + return; + } + + const generation = this.#generation; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Submitting consents...'; + // Iron has no MoonPay session/access tokens. + state.sessionToken = null; + state.accessToken = null; + }); + + try { + await this.messenger.call('KycService:submitConsents', { + ironDisclaimerIds: acceptedDisclaimerIds, + sumsubTncSigned: consents.sumsubTncSigned, + idosTncSigned: consents.idosTncSigned, + }); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Starting document verification...'; + }); + const sumsubResult = await this.startSumSub(); + if (this.#generation !== generation) { + return; + } + const sumsubError = sumsubResult?.error; + if (typeof sumsubError === 'string') { + throw new Error(sumsubError); + } + // After SumSub, refresh user-keyed status for the Money toast and start + // polling while still pending. Soft-fail: toast refresh must not rewind + // the consent / SumSub outcome. + try { + await this.refreshKycStatus(); + } catch (statusError) { + console.error('KYC status refresh failed:', statusError); + } + this.#updateIfCurrent(generation, (state) => { + if (state.phase !== 'error' && state.phase !== 'done') { + state.phase = 'done'; + state.statusMessage = 'KYC submitted.'; + } + }); + } catch (error) { + console.error('Iron session failed:', error); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.error = `Iron session failed: ${String(error)}`; + state.statusMessage = + 'Consent / verification failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + /** * Creates a vendor session from the currently stored terms + email. */ @@ -1037,6 +1299,26 @@ export class KycController extends BaseController< return this.state.kycRequiredByProduct[params.product]; } + /** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}). + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ + getCustomerIdentity(): KycCustomerIdentity | null { + const { moonpayCustomerId, activeVendor } = this.state; + if (!moonpayCustomerId) { + return null; + } + return { vendor: activeVendor, id: moonpayCustomerId }; + } + /** * Runs the SumSub document-verification sub-flow end to end: * @@ -1142,14 +1424,20 @@ export class KycController extends BaseController< expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), }); + const isIron = this.state.activeVendor === 'iron'; const { sessionId, kycStatus, finalStatus } = await this.messenger.call( 'KycService:createUkycSession', { jwtToken, - vendorMetadata: { - moonPayAccessToken: this.state.accessToken, - moonPayUserId: this.state.moonpayCustomerId, - }, + vendorId: isIron ? 'iron' : 'moonpay', + ...(isIron + ? {} + : { + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }), wrappedEncryptionKey, ukycCapabilityToken, }, @@ -1256,6 +1544,28 @@ export class KycController extends BaseController< } return result; } catch (error) { + // Applicant already finished KYC — treat as completed for Money toast. + if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) { + // A reset() may have landed while `launch` was in flight; forcing + // `completed` (and publishing `statusChanged`) on an idle controller + // would resurrect a flow the consumer already tore down. + if (this.#generation !== generation) { + return { alreadyCompleted: true }; + } + this.#applyUserStatus({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = { alreadyCompleted: true }; + state.statusMessage = 'KYC already completed.'; + state.phase = 'done'; + state.error = null; + }); + return { alreadyCompleted: true }; + } const result = { error: String(error) }; this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'failed'; @@ -1265,6 +1575,139 @@ export class KycController extends BaseController< } } + /** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ + async refreshKycStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const payload = await this.#fetchAndApplyUserStatus(); + if (payload.status === 'pending') { + this.#ensureUserStatusPolling(); + } else { + this.#stopUserStatusPolling(); + } + return payload; + } + + /** + * Fetches `GET /kyc/status` and applies it to state without managing the + * poll loop (used by both {@link refreshKycStatus} and the poll tick). + * + * @returns The latest status payload. + */ + async #fetchAndApplyUserStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const generation = this.#generation; + const response = await this.messenger.call('KycService:fetchKycStatus'); + if (this.#generation !== generation) { + return { + status: this.state.userStatus ?? 'not-started', + sumsubSessionId: this.state.userStatusSumsubSessionId, + errorCode: this.state.userStatusErrorCode, + }; + } + const payload = { + status: response.status, + sumsubSessionId: response.sumsubSessionId ?? null, + errorCode: response.errorCode ?? null, + }; + this.#applyUserStatus(payload); + return payload; + } + + /** + * Writes user-keyed status onto state and publishes `statusChanged` when the + * value actually changes. + * + * @param payload - The status payload to apply. + * @param payload.status - User-keyed KYC status from `GET /kyc/status`. + * @param payload.sumsubSessionId - Optional SumSub session id from status. + * @param payload.errorCode - Optional error code from status. + */ + #applyUserStatus(payload: { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }): void { + const previous = this.state.userStatus; + this.#applyUpdate((state) => { + state.userStatus = payload.status; + state.userStatusSumsubSessionId = payload.sumsubSessionId; + state.userStatusErrorCode = payload.errorCode; + }); + if (previous !== payload.status) { + this.messenger.publish(`${controllerName}:statusChanged`, payload); + } + } + + /** + * Starts the user-status poll loop when not already running and status is + * still `pending`. + */ + #ensureUserStatusPolling(): void { + if (this.#userStatusPollTimer !== null) { + return; + } + const token = this.#userStatusPollToken; + const tick = async (): Promise => { + try { + const payload = await this.#fetchAndApplyUserStatus(); + // Race with `reset()` / `#stopUserStatusPolling` while the request was + // in flight — do not reschedule onto an idle controller. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + if (payload.status !== 'pending') { + this.#stopUserStatusPolling(); + return; + } + } catch { + // Keep polling on transient errors, unless the loop was superseded. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + } + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + // Allow the process to exit while a pending-status poll is scheduled. + // React Native / browser timers are numbers with no `unref`, hence the + // optional call. + this.#userStatusPollTimer.unref?.(); + }; + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + this.#userStatusPollTimer.unref?.(); + } + + /** + * Stops the user-keyed status poll loop. + */ + #stopUserStatusPolling(): void { + this.#userStatusPollToken += 1; + if (this.#userStatusPollTimer !== null) { + clearTimeout(this.#userStatusPollTimer); + this.#userStatusPollTimer = null; + } + } + /** * Fetches the current UKYC session status for the active sub-flow and records * it on state. Useful for a one-off refresh outside the automatic polling @@ -1397,6 +1840,7 @@ export class KycController extends BaseController< // Stop any session-status polling so a late poll cannot write onto the // now-idle controller. this.#stopPolling(); + this.#stopUserStatusPolling(); // Invalidate any in-flight async work started before this reset so its // results are discarded rather than written onto the now-idle controller. this.#generation += 1; @@ -1409,6 +1853,7 @@ export class KycController extends BaseController< state.sessionToken = null; state.accessToken = null; state.moonpayCustomerId = null; + state.activeVendor = 'moonpay'; state.activeProduct = null; state.sumsub = { status: 'idle', @@ -1458,7 +1903,9 @@ export class KycController extends BaseController< */ #applyUpdate(updater: (state: KycControllerState) => void): void { this.update((state) => { - // @ts-expect-error Avoid "type instantiation is excessively deep". + // `@ts-expect-error` cannot be used: ts-bridge does not surface + // TS2589, so the directive is unused and fails the build. + // type issue only happens at the IDE level. updater(state); }); } diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 2d642d9ab27..7f38f86aa14 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -53,6 +53,66 @@ export type KycServiceCheckKycRequiredAction = { handler: KycService['checkKycRequired']; }; +/** + * Creates (or resumes) an Iron empty-shell customer for the authenticated + * canonical user. Must run before showing Iron T&C so the customer exists in + * `SigningsRequired` and resume logic can key off Iron status. + * + * @param params - The parameters. + * @param params.email - Email associated with the Iron customer. + * @returns The Iron customer record (subset validated for controller use). + */ +export type KycServiceCreateIronCustomerAction = { + type: `KycService:createIronCustomer`; + handler: KycService['createIronCustomer']; +}; + +/** + * Fetches Iron disclaimers / terms the customer must accept before consents + * and the SumSub sub-flow. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchIronDisclaimersAction = { + type: `KycService:fetchIronDisclaimers`; + handler: KycService['fetchIronDisclaimers']; +}; + +/** + * Checks whether Iron still requires KYC for the authenticated canonical + * user. Unlike the MoonPay variant, this does not take an access token. + * + * @returns Whether KYC is required. + */ +export type KycServiceCheckIronKycRequiredAction = { + type: `KycService:checkIronKycRequired`; + handler: KycService['checkIronKycRequired']; +}; + +/** + * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * authenticated user. The API responds with 204 No Content on success. + * + * @param params - The consent parameters. + */ +export type KycServiceSubmitConsentsAction = { + type: `KycService:submitConsents`; + handler: KycService['submitConsents']; +}; + +/** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ +export type KycServiceFetchKycStatusAction = { + type: `KycService:fetchKycStatus`; + handler: KycService['fetchKycStatus']; +}; + /** * Requests a per-session wrapping key from the UKYC backend. * @@ -133,6 +193,11 @@ export type KycServiceMethodActions = | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction + | KycServiceCreateIronCustomerAction + | KycServiceFetchIronDisclaimersAction + | KycServiceCheckIronKycRequiredAction + | KycServiceSubmitConsentsAction + | KycServiceFetchKycStatusAction | KycServiceGetWrappingKeyAction | KycServiceFetchJwksAction | KycServiceCreateUkycSessionAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 59bf7090020..c7d5d6c9db9 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -87,6 +87,13 @@ describe('KycService', () => { ).rejects.toThrow(/Malformed response received from disclaimers API/u); }); + it('throws when no bearer token is available', async () => { + const { service } = getService({ bearerToken: '' }); + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); + }); + it('throws an HttpError on a non-ok response', async () => { nock(MOCK_API_URL) .get('/vendors/moonpay/disclaimers') @@ -230,8 +237,8 @@ describe('KycService', () => { }); it('throws when no Fractal base URL is configured', async () => { - // An empty base URL exercises the constructor's "not configured" guard. - const { service } = getService({ fractalEncryptionBaseUrl: '' }); + // Omit the option entirely so the constructor falls back to ''. + const { service } = getService({ fractalEncryptionBaseUrl: null }); await expect(service.fetchJwks()).rejects.toThrow( /fractalEncryptionBaseUrl is not configured/u, @@ -417,6 +424,276 @@ describe('KycService', () => { service.getSessionStatus({ sessionId: 'sid' }), ).rejects.toThrow(/failed with status '404'/u); }); + + it('includes the API error message in HttpError when present', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('includes the API error field in HttpError when message is absent', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('prefers a string error field when message is not a string', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 123, error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('falls back to status-only HttpError when the body has no useful fields', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 1, error: 2 }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + + it('falls back to status-only HttpError when the body is not an object', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(409, null); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + }); + + describe('createIronCustomer', () => { + it('creates an Iron customer and returns the validated subset', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/customers', { email: 'a@b.co' }) + .reply(200, { + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + customer_type: 'Person', + name: '', + partner_id: 'p', + identification_ids: [], + signing_ids: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }); + const { service } = getService(); + + expect( + await service.createIronCustomer({ email: 'a@b.co' }), + ).toMatchObject({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/customers').reply(200, {}); + const { service } = getService(); + + await expect( + service.createIronCustomer({ email: 'a@b.co' }), + ).rejects.toThrow(/Malformed response received from iron customers API/u); + }); + }); + + describe('fetchIronDisclaimers', () => { + it('returns Iron disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Iron Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect( + await service.fetchIronDisclaimers({ country: 'USA' }), + ).toStrictEqual(disclaimers); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchIronDisclaimers({ country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from iron disclaimers API/u, + ); + }); + }); + + describe('checkIronKycRequired', () => { + it('returns whether Iron KYC is required', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/kyc-required') + .reply(200, { required: true }); + const { service } = getService(); + + expect(await service.checkIronKycRequired()).toStrictEqual({ + kycRequired: true, + }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect(service.checkIronKycRequired()).rejects.toThrow( + /Malformed response received from iron kyc-required API/u, + ); + }); + }); + + describe('submitConsents', () => { + it('posts consents and accepts a 204 response', async () => { + nock(MOCK_API_URL) + .post('/consents', { + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + kycLevel: 'standard', + }) + .reply(204); + const { service } = getService(); + + expect( + await service.submitConsents({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }), + ).toBeUndefined(); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).post('/consents').reply(500); + const { service } = getService(); + + await expect( + service.submitConsents({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('fetchKycStatus', () => { + it('returns the simplified user-keyed status', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { + status: 'pending', + sumsubSessionId: 'ss-1', + }); + const { service } = getService(); + + expect(await service.fetchKycStatus()).toStrictEqual({ + status: 'pending', + sumsubSessionId: 'ss-1', + }); + }); + + it('throws on an unknown status value', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' }); + const { service } = getService(); + + await expect(service.fetchKycStatus()).rejects.toThrow( + /Malformed response received from kyc status API/u, + ); + }); + }); + + describe('createUkycSession vendorId', () => { + it('defaults vendorId to moonpay and forwards vendorMetadata', async () => { + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }); + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'moonpay' && + body.vendorMetadata?.moonPayAccessToken === 'tok' + ); + }) + .reply(200, { sessionId: 'sid' }); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { moonPayAccessToken: 'tok' }, + wrappedEncryptionKey: { + sessionId: 'wk', + encryptedKey: 'ek', + nonce: 'n', + }, + ukycCapabilityToken, + }), + ).toStrictEqual({ sessionId: 'sid' }); + }); + + it('sends vendorId iron with empty vendorMetadata when omitted', async () => { + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }); + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'iron' && + JSON.stringify(body.vendorMetadata) === '{}' + ); + }) + .reply(200, { sessionId: 'sid-iron' }); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorId: 'iron', + wrappedEncryptionKey: { + sessionId: 'wk', + encryptedKey: 'ek', + nonce: 'n', + }, + ukycCapabilityToken, + }), + ).toStrictEqual({ sessionId: 'sid-iron' }); + }); }); describe('baseUrl', () => { @@ -435,6 +712,12 @@ describe('KycService', () => { disclaimers, ); }); + + it('throws when baseUrl is empty', () => { + expect(() => getService({ baseUrl: '' })).toThrow( + 'KycService: baseUrl is required', + ); + }); }); describe('messenger actions', () => { @@ -468,8 +751,8 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - Base URL of the KYC API. - * @param args.fractalEncryptionBaseUrl - Fractal base URL; pass `''` to - * exercise the service's "not configured" guard. + * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the + * option so the service falls back to an empty string. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -477,13 +760,15 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl = MOCK_API_URL, + // `null` means "omit the option entirely" (exercises the constructor's + // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; - fractalEncryptionBaseUrl?: string; + fractalEncryptionBaseUrl?: string | null; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -514,9 +799,10 @@ function getService({ ); const service = new KycService({ + fetch, messenger, baseUrl, - fractalEncryptionBaseUrl, + ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index a6d92536de2..89201eb63a0 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -14,6 +14,7 @@ import { array, assert, boolean, + enums, optional, string, StructError, @@ -25,7 +26,12 @@ import type { QueryClientConfig } from '@tanstack/query-core'; import { alpha2ToAlpha3 } from './countryCodes.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; -import type { KycDisclaimer, KycSessionStatus } from './types.js'; +import type { + KycDisclaimer, + KycSessionStatus, + KycUserStatusResponse, + KycVendor, +} from './types.js'; import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; @@ -44,6 +50,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchDisclaimers', 'createSession', 'checkKycRequired', + 'createIronCustomer', + 'fetchIronDisclaimers', + 'checkIronKycRequired', + 'submitConsents', + 'fetchKycStatus', 'getWrappingKey', 'fetchJwks', 'createUkycSession', @@ -111,17 +122,21 @@ export type KycServiceMessenger = Messenger< */ export type KycServiceOptions = { messenger: KycServiceMessenger; + fetch: typeof fetch; /** - * Base url of the KYC api + * Mandatory value that sets the base url to KYC api */ baseUrl: string; /** - * Base URL of the Fractal encryption api + * Base URL of the Fractal encryption service, from which the JWKS used to + * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is + * fetched. Required to run the wrapping-key exchange in + * {@link KycService.fetchJwks}. */ - fractalEncryptionBaseUrl: string; + fractalEncryptionBaseUrl?: string; /** * Shared configuration applied to all queries exposed by the service (e.g. a - * default `staleTime`/`gcTime`). Each data service gets its own + * default `staleTime`/`cacheTime`). Each data service gets its own * `QueryClient`. */ queryClientConfig?: QueryClientConfig; @@ -196,6 +211,29 @@ const SessionStatusResponseStruct = type({ vendorStatus: string(), }); +// Iron customer subset — `type` (not `object`) keeps extra Iron fields from +// failing validation while still requiring the fields the controller needs. +const IronCustomerResponseStruct = type({ + id: string(), + email: string(), + status: string(), +}); +export type IronCustomerResponse = Infer; + +const KYC_USER_STATUSES = [ + 'not-started', + 'pending', + 'need-more-information', + 'terminal-failure', + 'completed', +] as const; + +const KycUserStatusResponseStruct = type({ + status: enums([...KYC_USER_STATUSES]), + sumsubSessionId: optional(string()), + errorCode: optional(string()), +}); + // === PARAM TYPES === export type CreateSessionParams = { @@ -210,6 +248,17 @@ export type CheckKycRequiredParams = { capabilities?: { product: string }[]; }; +export type CreateIronCustomerParams = { + email: string; +}; + +export type SubmitConsentsParams = { + ironDisclaimerIds: string[]; + sumsubTncSigned: boolean; + idosTncSigned: boolean; + kycLevel?: 'standard'; +}; + export type GetWrappingKeyParams = { sessionClientPublicKey: string; }; @@ -227,7 +276,17 @@ export type WrappedEncryptionKey = { export type CreateUkycSessionParams = { jwtToken: string; - vendorMetadata: Record; + /** + * Identity vendor for the UKYC session. Defaults to `moonpay` for the + * existing Check/Auth flow. Pass `iron` for the Money/VBA path (no MoonPay + * metadata required). + */ + vendorId?: KycVendor; + /** + * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` / + * `moonPayUserId`); optional / omitted for Iron. + */ + vendorMetadata?: Record; wrappedEncryptionKey: WrappedEncryptionKey; /** * The client-signed `ukyc_capability_token` (envelope: payload + Ed25519 @@ -249,7 +308,7 @@ export type GetSessionStatusParams = { /** * `KycService` communicates with the Universal KYC (UKYC) backend to drive the * identity + document-verification flow. It is stateless and platform-agnostic: - * HTTP is performed through the global `fetch`, and the auth bearer token and + * HTTP is performed through an injected `fetch`, and the auth bearer token and * geolocation come from other controllers via the messenger. * * It extends {@link BaseDataService}, so every request is routed through @@ -257,12 +316,14 @@ export type GetSessionStatusParams = { * breaker) and its result is exposed via the service's `QueryClient`. Read-only * endpoints (`fetchDisclaimers`, `fetchJwks`) are cached with a `staleTime`; * the session-creating and status-polling endpoints opt out of caching - * (`staleTime`/`gcTime` of `0`) so they never serve a stale result. + * (`staleTime`/`cacheTime` of `0`) so they never serve a stale result. */ export class KycService extends BaseDataService< typeof serviceName, KycServiceMessenger > { + readonly #fetch: typeof fetch; + readonly #baseUrl: string; readonly #fractalEncryptionBaseUrl: string; @@ -272,6 +333,7 @@ export class KycService extends BaseDataService< * * @param options - The constructor options. * @param options.messenger - The messenger suited for this service. + * @param options.fetch - A function used to make HTTP requests. * @param options.baseUrl - Base URL of the KYC API * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key @@ -282,6 +344,7 @@ export class KycService extends BaseDataService< */ constructor({ messenger, + fetch: fetchFunction, baseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, @@ -293,8 +356,12 @@ export class KycService extends BaseDataService< queryClientConfig, policyOptions, }); + this.#fetch = fetchFunction; + if (!baseUrl) { + throw new Error('KycService: baseUrl is required'); + } this.#baseUrl = baseUrl; - this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl; + this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -385,7 +452,7 @@ export class KycService extends BaseDataService< }), // A session-creating mutation must never serve a stale/cached result. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -424,7 +491,7 @@ export class KycService extends BaseDataService< }), // The requirement can change server-side, so always re-check. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); const { required } = this.#validateResponse( data, @@ -434,6 +501,141 @@ export class KycService extends BaseDataService< return { kycRequired: required }; } + /** + * Creates (or resumes) an Iron empty-shell customer for the authenticated + * canonical user. Must run before showing Iron T&C so the customer exists in + * `SigningsRequired` and resume logic can key off Iron status. + * + * @param params - The parameters. + * @param params.email - Email associated with the Iron customer. + * @returns The Iron customer record (subset validated for controller use). + */ + async createIronCustomer( + params: CreateIronCustomerParams, + ): Promise { + const url = new URL('/vendors/iron/customers', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createIronCustomer`, params.email], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ email: params.email }), + }), + // Customer creation/resume must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + IronCustomerResponseStruct, + 'iron customers', + ); + } + + /** + * Fetches Iron disclaimers / terms the customer must accept before consents + * and the SumSub sub-flow. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchIronDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/iron/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchIronDisclaimers`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'iron disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Checks whether Iron still requires KYC for the authenticated canonical + * user. Unlike the MoonPay variant, this does not take an access token. + * + * @returns Whether KYC is required. + */ + async checkIronKycRequired(): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/iron/kyc-required', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:checkIronKycRequired`], + queryFn: async () => + this.#requestJson(url, { method: 'POST', body: '{}' }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + cacheTime: 0, + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'iron kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * authenticated user. The API responds with 204 No Content on success. + * + * @param params - The consent parameters. + */ + async submitConsents(params: SubmitConsentsParams): Promise { + const url = new URL('/consents', this.#baseUrl); + await this.fetchQuery({ + queryKey: [ + `${this.name}:submitConsents`, + params.ironDisclaimerIds, + params.sumsubTncSigned, + params.idosTncSigned, + params.kycLevel ?? 'standard', + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + ironDisclaimerIds: params.ironDisclaimerIds, + sumsubTncSigned: params.sumsubTncSigned, + idosTncSigned: params.idosTncSigned, + kycLevel: params.kycLevel ?? 'standard', + }), + }), + staleTime: 0, + cacheTime: 0, + }); + } + + /** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ + async fetchKycStatus(): Promise { + const url = new URL('/kyc/status', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchKycStatus`], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for toast flips, so it must always be fresh. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + KycUserStatusResponseStruct, + 'kyc status', + ); + } + /** * Requests a per-session wrapping key from the UKYC backend. * @@ -463,7 +665,7 @@ export class KycService extends BaseDataService< }), // A per-session key exchange must always run fresh. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -519,10 +721,10 @@ export class KycService extends BaseDataService< this.#requestJson(url, { method: 'POST', body: JSON.stringify({ - vendorId: 'moonpay', + vendorId: params.vendorId ?? 'moonpay', vendorUserId: 'mockedId', jwtToken: params.jwtToken, - vendorMetadata: params.vendorMetadata, + vendorMetadata: params.vendorMetadata ?? {}, wrappedEncryptionKey: params.wrappedEncryptionKey, ukycCapabilityToken: encodeStorageAccessTokenForHeader( params.ukycCapabilityToken, @@ -531,7 +733,7 @@ export class KycService extends BaseDataService< }), // A session-creating mutation must never serve a stale/cached result. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -559,7 +761,7 @@ export class KycService extends BaseDataService< queryFn: async () => this.#requestJson(url, { method: 'POST' }), // Journeys are (re)created on demand; do not reuse a cached token. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -588,7 +790,7 @@ export class KycService extends BaseDataService< queryFn: async () => this.#requestJson(url, { method: 'GET' }), // Status is polled for a terminal decision, so it must always be fresh. staleTime: 0, - gcTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -633,6 +835,24 @@ export class KycService extends BaseDataService< } } + /** + * Gets the authenticated wallet bearer token. + * + * @returns The bearer token. + */ + async #getBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token - is the wallet signed in?', + ); + } + return bearerToken; + } + /** * Performs a single JSON request. * @@ -665,24 +885,41 @@ export class KycService extends BaseDataService< } if (authenticated) { - const bearerToken = await this.messenger.call( - 'AuthenticationController:getBearerToken', - ); - assert(bearerToken, string()); - headers.Authorization = `Bearer ${bearerToken}`; + headers.Authorization = `Bearer ${await this.#getBearerToken()}`; } - const response = await fetch(url.toString(), { + const response = await this.#fetch(url.toString(), { ...init, headers, }); if (!response.ok) { + let detail = ''; + try { + const errorBody: unknown = await response.json(); + if (errorBody && typeof errorBody === 'object') { + const record = errorBody as Record; + if (typeof record.message === 'string') { + detail = record.message; + } else if (typeof record.error === 'string') { + detail = record.error; + } + } + } catch { + // Ignore body parse failures; status alone is still useful. + } throw new HttpError( response.status, - `Fetching '${url.toString()}' failed with status '${response.status}'`, + `Fetching '${url.toString()}' failed with status '${response.status}'${ + detail ? `: ${detail}` : '' + }`, ); } + // Consent (and similar) endpoints return 204 No Content. + if (response.status === 204) { + return null; + } + return (await response.json()) as Json; } } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 20830da2c55..d6b24b3730a 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -11,6 +11,7 @@ export type { KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, + KycControllerStatusChangedEvent, } from './KycController.js'; export type { KycControllerAcceptTermsAndStartSessionAction, @@ -19,11 +20,14 @@ export type { KycControllerBuildResetFrameUrlAction, KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, + KycControllerCreateIronCustomerAction, + KycControllerGetCustomerIdentityAction, KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, KycControllerInitializeAction, KycControllerLoadDisclaimersAction, + KycControllerRefreshKycStatusAction, KycControllerResetAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -32,10 +36,12 @@ export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse, CheckKycRequiredParams, + CreateIronCustomerParams, CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, GetWrappingKeyParams, + IronCustomerResponse, JwksResponse, KycServiceActions, KycServiceCacheUpdatedEvent, @@ -44,20 +50,26 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + SubmitConsentsParams, UkycSessionResponse, WrappedEncryptionKey, WrappingKeyResponse, } from './KycService.js'; export type { + KycServiceCheckIronKycRequiredAction, KycServiceCheckKycRequiredAction, + KycServiceCreateIronCustomerAction, KycServiceCreateJourneyAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, KycServiceFetchDisclaimersAction, + KycServiceFetchIronDisclaimersAction, KycServiceFetchJwksAction, + KycServiceFetchKycStatusAction, KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, + KycServiceSubmitConsentsAction, } from './KycService-method-action-types.js'; export { @@ -76,6 +88,7 @@ export type { } from './crypto.js'; export type { + KycCustomerIdentity, KycDisclaimer, KycPhase, KycProduct, @@ -83,6 +96,8 @@ export type { KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubStatus, + KycUserStatus, + KycUserStatusResponse, KycVendor, } from './types.js'; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 1323aea0c1b..9b1ee6ebfa8 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -8,29 +8,75 @@ /** * A MetaMask feature that consumes KYC. Used to key the per-product - * "is KYC required" cache so ramps and card can share one controller. + * "is KYC required" cache so ramps, card, and money can share one controller. */ -export type KycProduct = 'ramps' | 'card'; +export type KycProduct = 'ramps' | 'card' | 'money'; /** * Identity vendors supported behind the KYC surface. + * + * - `moonpay` — MoonPay Check/Auth frames + SumSub documents. + * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents → + * SumSub, with no MoonPay Check/Auth frames. + */ +export type KycVendor = 'moonpay' | 'iron'; + +/** + * Vendor-scoped identity for the currently authenticated KYC customer. + * + * Exposed to consumers (e.g. ramps) that must attach the vendor customer id to + * downstream provider calls without reading the full KYC state, which also + * holds session/access tokens. The identifier is session-scoped: it is only + * available once the customer has authenticated through the current flow and + * is cleared on `reset()`. + */ +export type KycCustomerIdentity = { + /** The identity vendor that issued {@link KycCustomerIdentity.id}. */ + vendor: KycVendor; + /** The vendor customer id (e.g. MoonPay customer UUID). */ + id: string; +}; + +/** + * User-keyed KYC status returned by `GET /kyc/status` and stored for Money + * toast / banner rendering. Collapses Iron + SumSub / relay state into the + * offsite contract. */ -export type KycVendor = 'moonpay'; +export type KycUserStatus = + | 'not-started' + | 'pending' + | 'need-more-information' + | 'terminal-failure' + | 'completed'; + +/** + * Payload from `GET /kyc/status`, including optional fields that power the + * 3-state error contract (retryable SumSub vs terminal vs EDD). + */ +export type KycUserStatusResponse = { + status: KycUserStatus; + /** Present when the user can reopen a SumSub session (retryable path). */ + sumsubSessionId?: string; + /** Machine-readable error code for terminal / EDD UX. */ + errorCode?: string; +}; /** * Phases of the end-to-end identity flow. * * - `idle` — nothing started. * - `terms` — waiting for the customer to accept the vendor terms. - * - `session` — creating the vendor session. - * - `check` — running the invisible connection-check frame. - * - `auth` — running the visible authentication (OTP) frame. + * - `session` — creating the vendor session (MoonPay) or posting consents + * (Iron). + * - `check` — running the invisible connection-check frame (MoonPay only). + * - `auth` — running the visible authentication (OTP) frame (MoonPay only). * - `form` — authenticated. When the flow is scoped to a product, the * KYC-required check runs automatically from here; otherwise the consumer - * drives it manually via `checkKycRequired`. - * - `submit` — submitting the KYC-required check. - * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is - * required, the document-verification sub-flow is launched automatically. + * drives it manually via `checkKycRequired`. Iron skips this phase. + * - `submit` — submitting the KYC-required check / launching SumSub. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` / + * `userStatus`. When KYC is required, the document-verification sub-flow is + * launched automatically. * - `error` — flow halted; see `error`. */ export type KycPhase = diff --git a/packages/messenger/CHANGELOG.md b/packages/messenger/CHANGELOG.md index e41e6cbeefd..60fabcc6039 100644 --- a/packages/messenger/CHANGELOG.md +++ b/packages/messenger/CHANGELOG.md @@ -13,11 +13,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Unlike `delegate`, this method requires all external actions and events to be listed, producing a TypeScript error showing exactly which items are missing. - Add `MessengerNamespace` utility type to extract the namespace from a Messenger type ([#8338](https://github.com/MetaMask/core/pull/8338)) -### Fixed - -- Defer re-entrant publishes of the same event so subscribers no longer receive a stale payload ([#9840](https://github.com/MetaMask/core/pull/9840)) - - When a subscriber publishes the same event it is currently handling (directly, or indirectly through a delegated messenger), that nested publish is now queued and delivered after the in-progress publish finishes, rather than inline. Previously the in-progress publish would resume afterwards and re-deliver its now-stale payload to the subscribers it had not yet reached. - ## [2.0.0] ### Added diff --git a/packages/messenger/src/Messenger.test.ts b/packages/messenger/src/Messenger.test.ts index dea73b23a71..3658cecaf9b 100644 --- a/packages/messenger/src/Messenger.test.ts +++ b/packages/messenger/src/Messenger.test.ts @@ -583,113 +583,6 @@ describe('Messenger', () => { expect(handler2.mock.calls).toHaveLength(1); }); - it('defers a re-entrant publish of the same event until the current publish finishes', () => { - type MessageEvent = { type: 'Fixture:message'; payload: [string] }; - const messenger = new Messenger<'Fixture', never, MessageEvent>({ - namespace: 'Fixture', - }); - - const calls: string[] = []; - let republished = false; - messenger.subscribe('Fixture:message', (message) => { - calls.push(`first:${message}`); - if (!republished) { - republished = true; - messenger.publish('Fixture:message', 'second'); - } - }); - messenger.subscribe('Fixture:message', (message) => { - calls.push(`second:${message}`); - }); - - messenger.publish('Fixture:message', 'first'); - - expect(calls).toStrictEqual([ - 'first:first', - 'second:first', - 'first:second', - 'second:second', - ]); - }); - - it('drains multiple re-entrant publishes of the same event in order', () => { - type MessageEvent = { type: 'Fixture:message'; payload: [string] }; - const messenger = new Messenger<'Fixture', never, MessageEvent>({ - namespace: 'Fixture', - }); - - const received: string[] = []; - let done = false; - messenger.subscribe('Fixture:message', (message) => { - received.push(message); - if (!done) { - done = true; - messenger.publish('Fixture:message', 'b'); - messenger.publish('Fixture:message', 'c'); - } - }); - - messenger.publish('Fixture:message', 'a'); - - expect(received).toStrictEqual(['a', 'b', 'c']); - }); - - it('runs a re-entrant publish of a different event inline', () => { - type MessageEvent = - | { type: 'Fixture:a'; payload: [] } - | { type: 'Fixture:b'; payload: [] }; - const messenger = new Messenger<'Fixture', never, MessageEvent>({ - namespace: 'Fixture', - }); - - const calls: string[] = []; - messenger.subscribe('Fixture:a', () => { - calls.push('a:start'); - messenger.publish('Fixture:b'); - calls.push('a:end'); - }); - messenger.subscribe('Fixture:b', () => { - calls.push('b'); - }); - - messenger.publish('Fixture:a'); - - expect(calls).toStrictEqual(['a:start', 'b', 'a:end']); - }); - - it('defers a re-entrant publish that crosses a delegated messenger', () => { - type ExampleEvent = { type: 'Source:event'; payload: [string] }; - const source = new Messenger<'Source', never, ExampleEvent>({ - namespace: 'Source', - }); - const delegatee = new Messenger<'Destination', never, ExampleEvent>({ - namespace: 'Destination', - }); - source.delegate({ messenger: delegatee, events: ['Source:event'] }); - - const calls: string[] = []; - let republished = false; - delegatee.subscribe('Source:event', (message) => { - calls.push(`delegatee:${message}`); - if (!republished) { - republished = true; - source.publish('Source:event', 'second'); - } - }); - source.subscribe('Source:event', (message) => { - calls.push(`source:${message}`); - }); - - source.publish('Source:event', 'first'); - - expect(calls).toStrictEqual([ - 'delegatee:first', - 'source:first', - 'delegatee:second', - 'source:second', - ]); - }); - describe('on first state change with an initial payload function registered', () => { it('publishes event if selected payload differs', () => { const state = { diff --git a/packages/messenger/src/Messenger.ts b/packages/messenger/src/Messenger.ts index e7cfc7bc9be..ba63f9ab405 100644 --- a/packages/messenger/src/Messenger.ts +++ b/packages/messenger/src/Messenger.ts @@ -262,13 +262,6 @@ export class Messenger< readonly #events = new Map>(); - /** - * In-progress publishes, keyed by event type. A key is present for the - * duration of a publish (so presence means "publishing"); its array collects - * re-entrant publishes of that event, drained when the publish finishes. - */ - readonly #deferredPublishes = new Map void)[]>(); - /** * The set of messengers we've delegated events to and their event handlers, by event type. */ @@ -655,37 +648,6 @@ export class Messenger< #publish( eventType: EventType, ...payload: ExtractEventPayload - ): void { - // Defer a re-entrant publish of the same event (e.g. a subscriber that - // publishes the event it is handling). Delivering it inline would let the - // in-progress publish resume and re-deliver its now-stale payload to the - // subscribers it had not reached yet. - const inProgress = this.#deferredPublishes.get(eventType); - if (inProgress) { - inProgress.push((): void => - this.#deliverToSubscribers(eventType, ...payload), - ); - return; - } - - const deferred: (() => void)[] = []; - this.#deferredPublishes.set(eventType, deferred); - try { - this.#deliverToSubscribers(eventType, ...payload); - - // Drain deferred publishes in order. The array grows as further - // re-entrant publishes push onto it; the iterator reads those too. - for (const run of deferred) { - run(); - } - } finally { - this.#deferredPublishes.delete(eventType); - } - } - - #deliverToSubscribers( - eventType: EventType, - ...payload: ExtractEventPayload ): void { const subscribers = this.#events.get(eventType); diff --git a/packages/money-account-api-data-service/CHANGELOG.md b/packages/money-account-api-data-service/CHANGELOG.md index 89370758228..fb9c3adf72c 100644 --- a/packages/money-account-api-data-service/CHANGELOG.md +++ b/packages/money-account-api-data-service/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [0.4.0] diff --git a/packages/money-account-api-data-service/package.json b/packages/money-account-api-data-service/package.json index 497d3c30d18..7c2503356a0 100644 --- a/packages/money-account-api-data-service/package.json +++ b/packages/money-account-api-data-service/package.json @@ -60,7 +60,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/money-account-api-data-service/src/money-account-api-data-service.ts b/packages/money-account-api-data-service/src/money-account-api-data-service.ts index f559b416c75..4335b66f613 100644 --- a/packages/money-account-api-data-service/src/money-account-api-data-service.ts +++ b/packages/money-account-api-data-service/src/money-account-api-data-service.ts @@ -376,7 +376,7 @@ export class MoneyAccountApiDataService extends BaseDataService< const normalizedAddress = address.toLowerCase(); const normalizedVault = options?.vaultAddress?.toLowerCase() ?? null; - return this.fetchInfiniteQuery( + return this.fetchInfiniteQuery( { queryKey: [ `${this.name}:fetchHistory`, @@ -385,8 +385,6 @@ export class MoneyAccountApiDataService extends BaseDataService< options?.chainId ?? null, options?.limit ?? null, ], - initialPageParam: null, - getNextPageParam: (result) => result.next_cursor, staleTime: DEFAULT_STALE_TIME_MS, queryFn: async (context) => { const cursor = context.pageParam as string | null | undefined; diff --git a/packages/multichain-account-service/CHANGELOG.md b/packages/multichain-account-service/CHANGELOG.md index dadc499b592..28f18a501d9 100644 --- a/packages/multichain-account-service/CHANGELOG.md +++ b/packages/multichain-account-service/CHANGELOG.md @@ -7,8 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [13.0.2] - ### Changed - Bump `@metamask/accounts-controller` from `^39.0.7` to `^39.1.0` ([#9807](https://github.com/MetaMask/core/pull/9807)) @@ -610,8 +608,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MultichainAccountService` ([#6141](https://github.com/MetaMask/core/pull/6141), [#6165](https://github.com/MetaMask/core/pull/6165)) - This service manages multichain accounts/wallets. -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.2...HEAD -[13.0.2]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.1...@metamask/multichain-account-service@13.0.2 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.1...HEAD [13.0.1]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@13.0.0...@metamask/multichain-account-service@13.0.1 [13.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@12.0.0...@metamask/multichain-account-service@13.0.0 [12.0.0]: https://github.com/MetaMask/core/compare/@metamask/multichain-account-service@11.1.0...@metamask/multichain-account-service@12.0.0 diff --git a/packages/multichain-account-service/package.json b/packages/multichain-account-service/package.json index 0a90c7240fa..7636a46a870 100644 --- a/packages/multichain-account-service/package.json +++ b/packages/multichain-account-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/multichain-account-service", - "version": "13.0.2", + "version": "13.0.1", "description": "Service to manage multichain accounts", "keywords": [ "Ethereum", diff --git a/packages/network-connection-banner-controller/CHANGELOG.md b/packages/network-connection-banner-controller/CHANGELOG.md index 3c0387d7ef1..231346f7838 100644 --- a/packages/network-connection-banner-controller/CHANGELOG.md +++ b/packages/network-connection-banner-controller/CHANGELOG.md @@ -9,13 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@metamask/network-enablement-controller` from `^6.0.3` to `^6.0.4` ([#9923](https://github.com/MetaMask/core/pull/9923)) - -## [0.2.0] - -### Changed - -- **BREAKING:** `NetworkConnectionBannerControllerMessenger` now requires `ClientController:stateChange` to be delegated instead of `ClientController:stateChanged` ([#9893](https://github.com/MetaMask/core/pull/9893)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/network-enablement-controller` from `^6.0.1` to `^6.0.3` ([#9740](https://github.com/MetaMask/core/pull/9740), [#9791](https://github.com/MetaMask/core/pull/9791)) - Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) @@ -42,8 +35,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 dismissal, and switching custom RPC endpoints to an available Infura endpoint ([#9041](https://github.com/MetaMask/core/pull/9041)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.2.0...HEAD -[0.2.0]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.2...@metamask/network-connection-banner-controller@0.2.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.2...HEAD [0.1.2]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.1...@metamask/network-connection-banner-controller@0.1.2 [0.1.1]: https://github.com/MetaMask/core/compare/@metamask/network-connection-banner-controller@0.1.0...@metamask/network-connection-banner-controller@0.1.1 [0.1.0]: https://github.com/MetaMask/core/releases/tag/@metamask/network-connection-banner-controller@0.1.0 diff --git a/packages/network-connection-banner-controller/README.md b/packages/network-connection-banner-controller/README.md index c17ce310bd5..7a0372d3391 100644 --- a/packages/network-connection-banner-controller/README.md +++ b/packages/network-connection-banner-controller/README.md @@ -13,7 +13,7 @@ from the same failure start and must be greater than the degraded one. The controller stays dormant after construction so the 5s / 30s escalation timers do not run before a user is actually looking at the wallet (e.g. while the app is still on the lock screen). It manages its own lifecycle by -subscribing to `ClientController:stateChange` and +subscribing to `ClientController:stateChanged` and `KeyringController:unlock` / `KeyringController:lock`: evaluation runs only while the client UI is open on an unlocked wallet. When either condition stops holding, pending timers are cancelled and the banner state resets to diff --git a/packages/network-connection-banner-controller/package.json b/packages/network-connection-banner-controller/package.json index 8505b36e61d..7b7938c0d33 100644 --- a/packages/network-connection-banner-controller/package.json +++ b/packages/network-connection-banner-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-connection-banner-controller", - "version": "0.2.0", + "version": "0.1.2", "description": "Decides when and how to surface the network connection banner based on RPC endpoint health", "keywords": [ "Ethereum", @@ -61,7 +61,7 @@ "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/network-controller": "^35.0.1", - "@metamask/network-enablement-controller": "^6.0.4", + "@metamask/network-enablement-controller": "^6.0.3", "@metamask/utils": "^11.11.0", "reselect": "^5.1.1" }, diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts index 847118212e0..efc25f75282 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.test.ts @@ -1843,8 +1843,7 @@ async function withController( 'NetworkEnablementController:stateChange', // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration 'ConnectivityController:stateChange', - // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration - 'ClientController:stateChange', + 'ClientController:stateChanged', 'KeyringController:unlock', 'KeyringController:lock', ], @@ -1858,7 +1857,7 @@ async function withController( }); const setUiOpen = (isUiOpen: boolean): void => { - rootMessenger.publish('ClientController:stateChange', { isUiOpen }, []); + rootMessenger.publish('ClientController:stateChanged', { isUiOpen }, []); }; const setKeyringUnlocked = (isUnlocked: boolean): void => { rootMessenger.publish( diff --git a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts index a3bbdd023e9..f3cc67e8079 100644 --- a/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts +++ b/packages/network-connection-banner-controller/src/NetworkConnectionBannerController.ts @@ -5,7 +5,7 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import { clientControllerSelectors } from '@metamask/client-controller'; -import type { ClientControllerStateChangeEvent } from '@metamask/client-controller'; +import type { ClientControllerState } from '@metamask/client-controller'; import { CONNECTIVITY_STATUSES, connectivityControllerSelectors, @@ -264,6 +264,16 @@ export type NetworkConnectionBannerControllerStateChangedEvent = export type NetworkConnectionBannerControllerEvents = NetworkConnectionBannerControllerStateChangedEvent; +/** + * Published when the state of `ClientController` changes. Defined here + * because the `client-controller` package still exports the legacy + * `:stateChange` event type. + */ +type ClientControllerStateChangedEvent = ControllerStateChangedEvent< + 'ClientController', + ClientControllerState +>; + /** * Events from other messengers that * {@link NetworkConnectionBannerControllerMessenger} subscribes to. @@ -272,7 +282,7 @@ type AllowedEvents = | NetworkControllerStateChangeEvent | NetworkEnablementControllerStateChangeEvent | ConnectivityControllerStateChangeEvent - | ClientControllerStateChangeEvent + | ClientControllerStateChangedEvent | KeyringControllerUnlockEvent | KeyringControllerLockEvent; @@ -464,8 +474,7 @@ export class NetworkConnectionBannerController extends BaseController< // Lifecycle: evaluate RPC health (and run the banner escalation timers) // only while the client UI is open on an unlocked wallet. this.messenger.subscribe( - // eslint-disable-next-line no-restricted-syntax -- awaiting upstream :stateChanged migration - 'ClientController:stateChange', + 'ClientController:stateChanged', (isUiOpen) => { this.#isUiOpen = isUiOpen; this.#updateLifecycle(); diff --git a/packages/network-enablement-controller/CHANGELOG.md b/packages/network-enablement-controller/CHANGELOG.md index 7d71d63962d..819aa7f61d8 100644 --- a/packages/network-enablement-controller/CHANGELOG.md +++ b/packages/network-enablement-controller/CHANGELOG.md @@ -7,12 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [6.0.4] - ### Changed - Bump `@metamask/transaction-controller` from `^69.5.0` to `^69.5.2` ([#9798](https://github.com/MetaMask/core/pull/9798), [#9823](https://github.com/MetaMask/core/pull/9823)) -- Bump `@metamask/config-registry-controller` from `^2.0.1` to `^3.0.0` ([#9923](https://github.com/MetaMask/core/pull/9923)) ## [6.0.3] @@ -403,8 +400,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6028](https://github.com/MetaMask/core/pull/6028)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.4...HEAD -[6.0.4]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.3...@metamask/network-enablement-controller@6.0.4 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.3...HEAD [6.0.3]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.2...@metamask/network-enablement-controller@6.0.3 [6.0.2]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.1...@metamask/network-enablement-controller@6.0.2 [6.0.1]: https://github.com/MetaMask/core/compare/@metamask/network-enablement-controller@6.0.0...@metamask/network-enablement-controller@6.0.1 diff --git a/packages/network-enablement-controller/package.json b/packages/network-enablement-controller/package.json index 80e6ecaee2c..085e29a027a 100644 --- a/packages/network-enablement-controller/package.json +++ b/packages/network-enablement-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/network-enablement-controller", - "version": "6.0.4", + "version": "6.0.3", "description": "Provides an interface to the currently enabled network using a MetaMask-compatible provider object", "keywords": [ "Ethereum", @@ -56,7 +56,7 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", - "@metamask/config-registry-controller": "^3.0.0", + "@metamask/config-registry-controller": "^2.0.1", "@metamask/controller-utils": "^12.3.0", "@metamask/keyring-api": "^24.0.0", "@metamask/messenger": "^2.0.0", diff --git a/packages/perps-controller/CHANGELOG.md b/packages/perps-controller/CHANGELOG.md index e358b3c2a5d..2e7dbf0a5a3 100644 --- a/packages/perps-controller/CHANGELOG.md +++ b/packages/perps-controller/CHANGELOG.md @@ -9,39 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `OrderParams.scaleSkew`, which weights a `scale` ladder's size across its rungs instead of spreading it evenly ([#9919](https://github.com/MetaMask/core/pull/9919)) - - Rung weights ramp linearly from 1 at `scaleMinPrice` to `scaleSkew` at `scaleMaxPrice`, in that direction for a buy and a sell alike. Above 1 puts more size at `scaleMaxPrice`, below 1 at `scaleMinPrice`; omitted or exactly 1 is the existing even split, unchanged. - - `splitScaleSizes` takes a matching optional `skew` and stays the single source of truth for the sizes, so a client previewing a ladder computes what placement submits. Sizes are allocated in whole size-grid units: each rung floors to its share and the leftover units go to the largest discarded fractions, ties by ascending index. The even split keeps putting its leftover on the first rung. - - A `scaleSkew` that is not a finite number above 0 is rejected by `validateOrderParams` with the existing `ORDER_SCALE_RANGE_INVALID` error code, and carrying it on any non-`scale` order type is rejected with the existing `ORDER_STRATEGY_PARAMS_NOT_SUPPORTED`. - - A skew that pushes a rung below the venue's per-order minimum or onto a zero size-grid slice is rejected before anything is signed, with the existing `ORDER_SCALE_NOTIONAL_TOO_SMALL` / `ORDER_SCALE_SIZE_TOO_SMALL`. -- Add `resolvePositionTriggerSummaryPrice` to `@metamask/perps-controller/utils`, which resolves the scalar TP/SL summary price a position reports for one direction from its trigger orders ([#9912](https://github.com/MetaMask/core/pull/9912)) - -### Fixed - -- Report the take profit (or stop loss) price on a `Position` when its only trigger for that direction is a partial, quantity-scoped one ([#9912](https://github.com/MetaMask/core/pull/9912)) - - `takeProfitPrice`/`stopLossPrice` were only ever scanned from position-bound triggers, so a position whose sole take profit closed it partially reported `takeProfitCount: 1` with no price, and clients rendering the scalar showed none. Applies to the REST `getPositions`, `getUserDataSnapshot`, and WebSocket position paths alike. - - Two or more triggers in a direction still report the scanned price, because no single price describes them and clients render the count instead. - -## [12.1.0] - -### Added - -- Add the optional `PerpsPerformance.onControllerConstructed` post-hydration timestamp hook ([#9906](https://github.com/MetaMask/core/pull/9906)) -- Add an explicit trace ID overload to `PerpsTracer.setMeasurement`, allowing clients to target preload measurements to their named trace ([#9906](https://github.com/MetaMask/core/pull/9906)) -- Add `PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE` (`previous_leverage`) for Perp UI Interaction `leverage_changed` events so clients can import the Segment property key from `@metamask/perps-controller` instead of a local interim constant ([#9881](https://github.com/MetaMask/core/pull/9881)) - -### Changed - -- Target market and user preload measurements to their named traces, and omit wallet addresses from user-preload trace data ([#9906](https://github.com/MetaMask/core/pull/9906)) - -## [12.0.0] - -### Added - -- **BREAKING:** Add `ordersSideFilter`, `ordersSortField`, and `ordersSortDirection` to the flat `ProLayoutPreferences` object (defaults `'all'`, `'time'`, `'desc'`) so Pro Orders panel side-filter and sort preferences persist independently of Positions across markets and app restarts via the existing `getProLayoutPreferences()` / `setProLayoutPreferences(patch)` API; export `ProOrdersSideFilter`, `ProOrdersSortField`, and `ProOrdersSortDirection` ([#9862](https://github.com/MetaMask/core/pull/9862)) - - Consumers that construct a full `ProLayoutPreferences` object (instead of using `DEFAULT_PRO_LAYOUT_PREFERENCES`, the getter, or the patch setter) must include the new fields. Persisted state that predates them remains valid at runtime because the getter/selector merge over defaults. - - Orders side filter (`all` | `long` | `short`) is independent of `positionsSideFilter`. Orders sort fields are `orderValue` | `size` | `price` | `time`. -- Add `PERPS_EVENT_PROPERTY.PERPS_MODE` (`perps_mode`) for Lite/Pro interface mode analytics (`'lite' | 'pro'`), distinct from existing `PERPS_EVENT_PROPERTY.MODE` (`mode`) which is search intent (`discovery` / `intent` / `browse`) ([#9819](https://github.com/MetaMask/core/pull/9819)) - **BREAKING:** Add `positionsSideFilter`, `positionsSortField`, and `positionsSortDirection` to the flat `ProLayoutPreferences` object (defaults `'all'`, `'positionValue'`, `'desc'`) so Pro Positions/Orders panel sort and side-filter preferences persist across markets and app restarts via the existing `getProLayoutPreferences()` / `setProLayoutPreferences(patch)` API; export `ProPositionsSideFilter`, `ProPositionsSortField`, and `ProPositionsSortDirection` ([#9838](https://github.com/MetaMask/core/pull/9838)) - Consumers that construct a full `ProLayoutPreferences` object (instead of using `DEFAULT_PRO_LAYOUT_PREFERENCES`, the getter, or the patch setter) must include the new fields. Persisted state that predates them remains valid at runtime because the getter/selector merge over defaults. - **BREAKING:** Add strategy placement order types to `OrderType`: `twap`, `scale`, and `chase`, placeable through `placeOrder` alongside the existing `market`, `limit`, and trigger types ([#9832](https://github.com/MetaMask/core/pull/9832)) @@ -80,36 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Invalid parameters are rejected with a typed `PERPS_ERROR_CODES` value, and nothing invalid is ever signed; see the new error codes entry below for the full list and for which few are decided after a read rather than before any request. - Add `twap`, `scale` and `chase` to `PERPS_EVENT_VALUE.ORDER_TYPE`, which dashboards key on and which `TradingService` emits verbatim ([#9832](https://github.com/MetaMask/core/pull/9832)) - Add the `StrategyOrderType` and `OrdinaryOrderType` types, plus `STRATEGY_ORDER_TYPES`, `isStrategyOrderType`, `SCALE_ORDER_COUNT`, `computeScalePriceLadder`, `splitScaleSizes`, `computeChaseQuotePrice`, `getPriceTick`, `CHASE_ORDER_CONFIG`, and `HYPERLIQUID_TWAP_LIMITS` ([#9832](https://github.com/MetaMask/core/pull/9832)) -- Add an optional schema-v2 Terminal market snapshot path with strict identity, freshness, completeness, unit, and payload validation before falling back to HyperLiquid. ([#9815](https://github.com/MetaMask/core/pull/9815)) -- Add `PerpsController.getUserDataSnapshot()` to fetch and cache positions, open orders, and account state as one account- and DEX-scoped result. ([#9815](https://github.com/MetaMask/core/pull/9815)) -- Add a subscription fee-waiver source to the MetaMask builder fee, wired through the optional `PerpsPlatformDependencies.subscription.getPerpsBenefits()` dependency, along with the `PerpsSubscriptionBenefits`, `PerpsSubscriptionUsage`, `PerpsSubscriptionFeeWaiverStatus`, `PerpsFeeSource`, and `PerpsFeeResolution` types and the `SUBSCRIPTION_BENEFITS_CACHE` constant ([#9857](https://github.com/MetaMask/core/pull/9857)) - - `RewardsIntegrationService.resolveFee()` returns the lowest fee across the default, rewards (VIP and season, already collapsed by `RewardsController`), and subscription sources, together with the winning source and the subscription gate outcome. The subscription source contributes `0` bips only when the eligibility gate — `status=active`, `perpsFeeWaiver` entitled, `usage=available`, not exhausted — passes on the cached benefits snapshot. - - `RewardsIntegrationService.resolveFee()` and `getSubscriptionFeeWaiverStatus()` are pure cache consumers and never start a subscription request on the order-signing path. `PerpsController.calculateFees()` owns preview hydration through `refreshSubscriptionBenefits()`. A snapshot older than `SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs` can no longer grant the waiver, and a failed or unreachable refresh falls back to the next-lowest source instead of erroring or over-granting. - - Refreshes are throttled on the last read _attempt_ rather than the last success, so a benefits outage retries at most once per `FreshMs` window instead of once per preview. - - `PerpsController.invalidateSubscriptionBenefits()` (also exposed as the `PerpsController:invalidateSubscriptionBenefits` messenger action) drops the cached snapshot. Call it on sign-out or a profile switch: the snapshot carries no profile identity, so without it the previous profile's benefits keep answering until the next successful refresh. A read already in flight when it is called is discarded rather than written back, so it cannot repopulate the cache for the previous identity. - - Clients that do not wire `subscription` are unaffected: the resolver keeps returning the rewards or default fee. -- Add `FeeCalculationResult.subscription`, surfacing the subscription waiver's `eligible`, `reason`, and `remainingNotionalUsd` on `PerpsController.calculateFees()` from the same cached benefits snapshot ([#9857](https://github.com/MetaMask/core/pull/9857)) - - The preview refreshes the benefits cache when needed, but does not adjust the quoted fee rates or mutate the notional cap. The field is omitted entirely when no `subscription` dependency is wired. ### Changed -- `RewardsIntegrationService.calculateUserFeeDiscount()` now returns the unified resolver's winning discount instead of the rewards discount alone, while preserving `undefined` when no source has resolved. TradingService passes the full `PerpsFeeResolution` to providers, isolates it across concurrent operations, and applies it to flip orders. HyperLiquid uses the configured subscription builder only after account-scoped approval through `PerpsController.approveSubscriptionBuilderFee()`; otherwise it uses the ordinary builder at the standard fee ([#9857](https://github.com/MetaMask/core/pull/9857)) - `getTriggerExecution` now reports `'limit'` for `scale` and `chase`, which rest limit orders on the book without carrying an `OrderParams.price`, and `'market'` for `twap`, whose suborders cross it ([#9832](https://github.com/MetaMask/core/pull/9832)) - This is what decides the fee tier and the max order value, so a scale ladder and a chase are no longer quoted at the taker rate or held to the tighter market-order cap. `calculateFees` additionally quotes `chase` at the maker rate regardless of `isMaker`, because a post-only order can only fill as a maker. - `isLimitExecutionOrderType` is unchanged: it answers the narrower question of whether `OrderParams.price` carries a real limit price, which for a strategy placement it does not. - `TriggerOrderType` is now spelled out as `'stop_market' | 'stop_limit' | 'take_profit_market' | 'take_profit_limit'` instead of being derived as `Exclude` ([#9832](https://github.com/MetaMask/core/pull/9832)) - The resolved type is unchanged for existing consumers. Deriving it meant that any order type added to `OrderType` that was neither `market` nor `limit` was pulled into the trigger union automatically and started demanding a trigger price it had no concept of. -- Reuse provider DEX discovery for subscriptions, and start account preloading independently from market preloading to reduce cold-start blocking. ([#9815](https://github.com/MetaMask/core/pull/9815)) -- Require a selected EVM address and the current Hyperliquid network/HIP-3/DEX identity before returning cached account data; legacy or mismatched entries now fail closed and refresh. ([#9815](https://github.com/MetaMask/core/pull/9815)) - -### Fixed - -- Prevent `CLIENT_NOT_INITIALIZED` errors during cold-start and reconnection by awaiting in-flight initialization in trading action methods (`placeOrder`, `editOrder`, `cancelOrder`, `closePosition`, `deposit`, `withdraw`, etc.) ([#9032](https://github.com/MetaMask/core/pull/9032)) -- Fix compound error string (`CLIENT_NOT_INITIALIZED: `) breaking i18n translation lookup — now always throws the plain `CLIENT_NOT_INITIALIZED` code ([#9032](https://github.com/MetaMask/core/pull/9032)) -- Recreate all four SDK clients (including `ExchangeClient` and HTTP `InfoClient`) during WebSocket reconnection so `isInitialized()` returns `true` after reconnect ([#9032](https://github.com/MetaMask/core/pull/9032)) -- Bring the HyperLiquid SDK clients up before the provider's first asset-metadata read, so a trading action taken during cold start or after a disconnect waits for the clients instead of failing with `CLIENT_NOT_INITIALIZED` ([#9865](https://github.com/MetaMask/core/pull/9865)) - - `placeOrder` resolves asset info before it ensures trading readiness, so waiting for controller initialization alone was not enough: the metadata read still hit an uninitialized `InfoClient` and the order failed. Warm reads are unaffected — the cached path returns before the client check. -- Publish WebSocket-backed SDK clients only after reconnection succeeds, while keeping HTTP-backed metadata and trading clients available during retries ([#9868](https://github.com/MetaMask/core/pull/9868)) ## [11.0.0] @@ -747,9 +692,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.18.0` to `^11.19.0` ([#7995](https://github.com/MetaMask/core/pull/7995)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@12.1.0...HEAD -[12.1.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@12.0.0...@metamask/perps-controller@12.1.0 -[12.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@11.0.0...@metamask/perps-controller@12.0.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@11.0.0...HEAD [11.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@10.0.0...@metamask/perps-controller@11.0.0 [10.0.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.3.0...@metamask/perps-controller@10.0.0 [9.3.0]: https://github.com/MetaMask/core/compare/@metamask/perps-controller@9.2.1...@metamask/perps-controller@9.3.0 diff --git a/packages/perps-controller/package.json b/packages/perps-controller/package.json index d2e099144de..16f2bbd6c86 100644 --- a/packages/perps-controller/package.json +++ b/packages/perps-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/perps-controller", - "version": "12.1.0", + "version": "11.0.0", "description": "Controller for perpetual trading functionality in MetaMask", "keywords": [ "Ethereum", @@ -110,7 +110,7 @@ "uuid": "^8.3.2" }, "devDependencies": { - "@metamask/account-tree-controller": "^8.0.0", + "@metamask/account-tree-controller": "^7.6.1", "@metamask/authenticated-user-storage": "^3.0.1", "@metamask/auto-changelog": "^6.1.0", "@metamask/geolocation-controller": "^1.0.0", diff --git a/packages/perps-controller/src/PerpsController-method-action-types.ts b/packages/perps-controller/src/PerpsController-method-action-types.ts index dcba771ad52..2c005917654 100644 --- a/packages/perps-controller/src/PerpsController-method-action-types.ts +++ b/packages/perps-controller/src/PerpsController-method-action-types.ts @@ -36,18 +36,6 @@ export type PerpsControllerGetCachedUserDataForActiveProviderAction = { handler: PerpsController['getCachedUserDataForActiveProvider']; }; -/** - * Fetch, validate, and atomically cache a complete user-data snapshot. - * This remains callable after mount so consumers can seed their live channel - * from one coherent positions/orders/account result. - * - * @returns The accepted user-data snapshot. - */ -export type PerpsControllerGetUserDataSnapshotAction = { - type: `PerpsController:getUserDataSnapshot`; - handler: PerpsController['getUserDataSnapshot']; -}; - /** * Initialize the PerpsController providers * Must be called before using any other methods @@ -778,32 +766,6 @@ export type PerpsControllerCalculateFeesAction = { handler: PerpsController['calculateFees']; }; -/** - * Approve the dedicated subscription builder outside order submission. - * Until this succeeds, subscription waivers fall back to the ordinary - * builder at the standard fee. - * - * @returns Whether the subscription builder is approved. - */ -export type PerpsControllerApproveSubscriptionBuilderFeeAction = { - type: `PerpsController:approveSubscriptionBuilderFee`; - handler: PerpsController['approveSubscriptionBuilderFee']; -}; - -/** - * Drop the cached subscription benefits snapshot. - * - * Call this when the identity behind the benefits changes — sign-out, or a - * profile switch. The snapshot carries no profile identity of its own, so - * without this it keeps answering for the previous profile until the next - * successful refresh. The next fee resolution reports the waiver as - * unavailable, so it is withheld until preview or lifecycle hydration. - */ -export type PerpsControllerInvalidateSubscriptionBenefitsAction = { - type: `PerpsController:invalidateSubscriptionBenefits`; - handler: PerpsController['invalidateSubscriptionBenefits']; -}; - /** * Disconnect provider and cleanup subscriptions * Call this when navigating away from Perps screens to prevent battery drain @@ -1161,7 +1123,6 @@ export type PerpsControllerIsCurrentlyReinitializingAction = { export type PerpsControllerMethodActions = | PerpsControllerGetCachedMarketDataForActiveProviderAction | PerpsControllerGetCachedUserDataForActiveProviderAction - | PerpsControllerGetUserDataSnapshotAction | PerpsControllerInitAction | PerpsControllerGetActiveProviderAction | PerpsControllerGetActiveProviderOrNullAction @@ -1223,8 +1184,6 @@ export type PerpsControllerMethodActions = | PerpsControllerSubscribeToOICapsAction | PerpsControllerSetLiveDataConfigAction | PerpsControllerCalculateFeesAction - | PerpsControllerApproveSubscriptionBuilderFeeAction - | PerpsControllerInvalidateSubscriptionBenefitsAction | PerpsControllerDisconnectAction | PerpsControllerStartEligibilityMonitoringAction | PerpsControllerStopEligibilityMonitoringAction diff --git a/packages/perps-controller/src/PerpsController.ts b/packages/perps-controller/src/PerpsController.ts index 9e3c03712d3..243d6d921e6 100644 --- a/packages/perps-controller/src/PerpsController.ts +++ b/packages/perps-controller/src/PerpsController.ts @@ -5,7 +5,6 @@ import type { import { BaseController, ControllerGetStateAction, - ControllerStateChangedEvent, ControllerStateChangeEvent, StateMetadata, } from '@metamask/base-controller'; @@ -20,12 +19,7 @@ import { PERPS_EVENT_PROPERTY, PERPS_EVENT_VALUE, } from './constants/eventNames.js'; -import { - canonicalizeHyperLiquidDexes, - MAINNET_HIP3_CONFIG, - TESTNET_HIP3_CONFIG, - USDC_SYMBOL, -} from './constants/hyperLiquidConfig.js'; +import { USDC_SYMBOL } from './constants/hyperLiquidConfig.js'; import { PerpsMeasurementName } from './constants/performanceMetrics.js'; import type { SortOptionId, @@ -36,6 +30,7 @@ import { PERPS_CONSTANTS, MARKET_SORTING_CONFIG, PROVIDER_CONFIG, + PERPS_DISK_CACHE_USER_DATA, buildProviderCacheKey, MAX_SLIPPAGE_BOUNDS, DEFAULT_PERPS_MODE, @@ -125,7 +120,6 @@ import type { PerpsAnalyticsProperties, PerpsAttributionContext, PerpsProviderType, - PerpsUserDataSnapshot, PerpsSelectedPaymentToken, PerpsRemoteFeatureFlagState, PerpsTransactionParams, @@ -147,7 +141,6 @@ import { getSelectedEvmAccountFromMessenger } from './utils/accountUtils.js'; import { ensureError } from './utils/errorUtils.js'; import { parseAssetName } from './utils/hyperLiquidAdapter.js'; import { - clonePerpsMarketData, compileMarketPattern, shouldIncludeMarket, } from './utils/marketUtils.js'; @@ -157,49 +150,11 @@ import { persistMarketEntriesToDisk, persistUserEntriesToDisk, } from './utils/perpsDiskPersistence.js'; -import type { DiskCacheUserEntry } from './utils/perpsDiskPersistence.js'; import { wait } from './utils/wait.js'; /** Derived type for logger options from PerpsLogger interface */ type PerpsLoggerOptions = Parameters[1]; -function cloneUserDataSnapshot( - snapshot: PerpsUserDataSnapshot, -): PerpsUserDataSnapshot { - return { - positions: snapshot.positions.map((position) => ({ - ...position, - leverage: { ...position.leverage }, - cumulativeFunding: { ...position.cumulativeFunding }, - ...(position.takeProfitOrders && { - takeProfitOrders: position.takeProfitOrders.map((order) => ({ - ...order, - })), - }), - ...(position.stopLossOrders && { - stopLossOrders: position.stopLossOrders.map((order) => ({ - ...order, - })), - }), - })), - orders: snapshot.orders.map((order) => ({ ...order })), - accountState: { - ...snapshot.accountState, - ...(snapshot.accountState.subAccountBreakdown && { - subAccountBreakdown: Object.fromEntries( - Object.entries(snapshot.accountState.subAccountBreakdown).map( - ([dex, balances]) => [dex, { ...balances }], - ), - ), - }), - }, - identity: { - ...snapshot.identity, - dexes: [...snapshot.identity.dexes], - }, - }; -} - /** * Returns the first non-empty string from the given values. * Env vars default to '' (not null/undefined), so ?? wouldn't fall through. @@ -297,9 +252,6 @@ export { } from './constants/perpsConfig.js'; export type { ProLayoutPreferences, - ProOrdersSideFilter, - ProOrdersSortDirection, - ProOrdersSortField, ProPositionsSideFilter, ProPositionsSortDirection, ProPositionsSortField, @@ -490,18 +442,11 @@ export type PerpsControllerState = { // Keyed by "providerId:network" (e.g. 'hyperliquid:mainnet', 'myx:testnet') cachedMarketDataByProvider: Record< string, - { - data: PerpsMarketData[]; - timestamp: number; - sourceExpiresAt?: number; - hip3ConfigVersion?: number; - dexes?: string[]; - } + { data: PerpsMarketData[]; timestamp: number } >; // Cached user data from background preloading (REST snapshots, not WebSocket) - // Keyed by "providerId:network". The entry carries the selected address and - // exact HyperLiquid configuration identity, both validated before reads. + // Keyed by "providerId:network" (e.g. 'hyperliquid:mainnet', 'myx:testnet') cachedUserDataByProvider: Record< string, { @@ -510,8 +455,6 @@ export type PerpsControllerState = { accountState: AccountState | null; timestamp: number; address: string; - hip3ConfigVersion?: number; - dexes?: string[]; } >; }; @@ -790,9 +733,10 @@ const metadata: StateMetadata = { /** * PerpsController events */ -export type PerpsControllerEvents = - | ControllerStateChangeEvent<'PerpsController', PerpsControllerState> - | ControllerStateChangedEvent<'PerpsController', PerpsControllerState>; +export type PerpsControllerEvents = ControllerStateChangeEvent< + 'PerpsController', + PerpsControllerState +>; /** * The action which can be used to retrieve the state of the @@ -848,18 +792,7 @@ type BlockedRegionList = { source: 'remote' | 'fallback'; }; -type UserSnapshotContext = { - provider: PerpsProvider; - standaloneProvider: HyperLiquidProvider | null; - address: string; - isTestnet: boolean; - hip3ConfigVersion: number; - expectedDexes: string[]; - isCurrent: () => boolean; -}; - const MESSENGER_EXPOSED_METHODS = [ - 'approveSubscriptionBuilderFee', 'calculateFees', 'calculateLiquidationPrice', 'calculateMaintenanceMargin', @@ -887,7 +820,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'getBlockExplorerUrl', 'getCachedMarketDataForActiveProvider', 'getCachedUserDataForActiveProvider', - 'getUserDataSnapshot', 'getCurrentNetwork', 'getFunding', 'getHistoricalPortfolio', @@ -909,7 +841,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'getWithdrawalProgress', 'getWithdrawalRoutes', 'init', - 'invalidateSubscriptionBenefits', 'isCurrentlyReinitializing', 'isFirstTimeUserOnCurrentNetwork', 'isWatchlistMarket', @@ -1086,11 +1017,6 @@ export class PerpsController extends BaseController< #standaloneProviderHip3Version: number | null = null; - readonly #standaloneProviderOperations = new Map< - PerpsProvider, - Set> - >(); - #eligibilityCheckDeferred: boolean; /** @@ -1108,8 +1034,6 @@ export class PerpsController extends BaseController< */ #ausQueue: Promise = Promise.resolve(); - #userDiskWrite: Promise = Promise.resolve(); - // Store options for dependency injection (allows core package to inject platform-specific services) readonly #options: PerpsControllerOptions; @@ -1161,9 +1085,7 @@ export class PerpsController extends BaseController< ...infrastructure, terminalMarketService: infrastructure.terminalMarketService ?? - (infrastructure.terminalApi?.marketDataUrl || - infrastructure.terminalApiUrl || - infrastructure.terminalApi?.globalSnapshotUrl + (infrastructure.terminalApiUrl ? new TerminalMarketService(infrastructure) : undefined), }); @@ -1236,9 +1158,6 @@ export class PerpsController extends BaseController< // Eagerly hydrate in-memory caches from disk so hooks see data on first render. // Must happen at construction time — before any React component mounts. this.#hydrateCacheFromDiskSync(); - this.#options.infrastructure.performance.onControllerConstructed?.( - this.#options.infrastructure.performance.now(), - ); } // ============================================================================ @@ -1268,19 +1187,6 @@ export class PerpsController extends BaseController< this.#options.infrastructure.debugLogger.log(...args); } - /** - * Awaits the in-flight initialization promise if init is currently running. - * Called internally by #getActiveProviderWhenReady(). - */ - async #awaitInitializationIfInProgress(): Promise { - if ( - this.state.initializationState === InitializationState.Initializing && - this.#initializationPromise - ) { - await this.#initializationPromise; - } - } - /** * Resolve the provider ids that should participate in aggregated cache reads. * @@ -1340,6 +1246,7 @@ export class PerpsController extends BaseController< if (activeProvider === 'aggregated') { // Assemble from all registered provider entries const assembled: PerpsMarketData[] = []; + let oldestTimestamp = Infinity; for (const providerId of this.#getAggregatedCacheProviderIds( Object.keys(cache), )) { @@ -1348,14 +1255,19 @@ export class PerpsController extends BaseController< if (!entry || entry.data.length === 0) { continue; } - if (!this.#isMarketCacheEntryCurrent(providerId, entry, options)) { - continue; - } - assembled.push(...clonePerpsMarketData(entry.data)); + oldestTimestamp = Math.min(oldestTimestamp, entry.timestamp); + assembled.push(...entry.data); } if (assembled.length === 0) { return null; } + // Check TTL against the oldest entry + if ( + !options?.skipTTL && + Date.now() - oldestTimestamp > PerpsController.#preloadGuardMs * 10 + ) { + return null; + } return assembled; } @@ -1365,33 +1277,13 @@ export class PerpsController extends BaseController< if (!entry || entry.data.length === 0) { return null; } - if (!this.#isMarketCacheEntryCurrent(activeProvider, entry, options)) { + if ( + !options?.skipTTL && + Date.now() - entry.timestamp > PerpsController.#preloadGuardMs * 10 + ) { return null; } - return clonePerpsMarketData(entry.data); - } - - #isMarketCacheEntryCurrent( - providerId: string, - entry: PerpsControllerState['cachedMarketDataByProvider'][string], - options?: { skipTTL?: boolean }, - ): boolean { - if (entry.sourceExpiresAt !== undefined) { - const expectedDexes = this.#getStaticSnapshotDexes(); - return ( - providerId === 'hyperliquid' && - Date.now() < entry.sourceExpiresAt && - entry.hip3ConfigVersion === this.state.hip3ConfigVersion && - expectedDexes !== undefined && - Array.isArray(entry.dexes) && - entry.dexes.length === expectedDexes.length && - entry.dexes.every((dex, index) => dex === expectedDexes[index]) - ); - } - return ( - options?.skipTTL === true || - Date.now() - entry.timestamp <= PerpsController.#preloadGuardMs * 10 - ); + return entry.data; } /** @@ -1420,22 +1312,14 @@ export class PerpsController extends BaseController< const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); currentAddress = evmAccount?.address ?? null; } catch { - // Account identity is required before account-scoped data can be trusted. + // Can't determine current account — trust the cache } - if (!currentAddress) { - return null; - } - const selectedAddress = currentAddress; - const skipTTL = options?.skipTTL ?? false; const isValidEntry = ( - providerId: string, - entry: - | PerpsControllerState['cachedUserDataByProvider'][string] - | undefined, - ): entry is PerpsControllerState['cachedUserDataByProvider'][string] => { + entry: { timestamp: number; address: string } | undefined, + ): entry is { timestamp: number; address: string } => { if (!entry) { return false; } @@ -1443,7 +1327,8 @@ export class PerpsController extends BaseController< return false; } if ( - !this.#isUserCacheIdentityCurrent(providerId, entry, selectedAddress) + currentAddress && + entry.address.toLowerCase() !== currentAddress.toLowerCase() ) { return false; } @@ -1460,12 +1345,9 @@ export class PerpsController extends BaseController< for (const providerId of this.#getAggregatedCacheProviderIds( Object.keys(cache), )) { - const providerNetworkKey = buildProviderCacheKey( - providerId, - this.state.isTestnet, - ); - const entry = cache[providerNetworkKey]; - if (!isValidEntry(providerId, entry)) { + const key = buildProviderCacheKey(providerId, this.state.isTestnet); + const entry = cache[key]; + if (!isValidEntry(entry)) { continue; } hasValidEntry = true; @@ -1489,12 +1371,9 @@ export class PerpsController extends BaseController< } // Single provider mode - const providerNetworkKey = buildProviderCacheKey( - activeProvider, - this.state.isTestnet, - ); - const entry = cache[providerNetworkKey]; - if (!entry || !isValidEntry(activeProvider, entry)) { + const key = buildProviderCacheKey(activeProvider, this.state.isTestnet); + const entry = cache[key]; + if (!entry || !isValidEntry(entry)) { return null; } @@ -1505,199 +1384,6 @@ export class PerpsController extends BaseController< }; } - #isUserCacheIdentityCurrent( - providerId: string, - entry: PerpsControllerState['cachedUserDataByProvider'][string], - address: string, - ): boolean { - if (entry.address.toLowerCase() !== address.toLowerCase()) { - return false; - } - if (providerId !== 'hyperliquid') { - return true; - } - - const expectedDexes = this.#getStaticSnapshotDexes(); - return ( - entry.hip3ConfigVersion === this.state.hip3ConfigVersion && - expectedDexes !== undefined && - Array.isArray(entry.dexes) && - entry.dexes.length === expectedDexes.length && - entry.dexes.every((dex, index) => dex === expectedDexes[index]) - ); - } - - /** - * Fetch, validate, and atomically cache a complete user-data snapshot. - * This remains callable after mount so consumers can seed their live channel - * from one coherent positions/orders/account result. - * - * @returns The accepted user-data snapshot. - */ - async getUserDataSnapshot(): Promise { - const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); - if (!evmAccount?.address) { - throw new Error('Cannot fetch user data snapshot without an EVM account'); - } - if (this.state.activeProvider !== 'hyperliquid') { - throw new Error('User data snapshots require Hyperliquid provider mode'); - } - - const capturedActiveProvider = this.activeProviderInstance; - const standaloneProvider = capturedActiveProvider - ? null - : this.#getOrCreateStandaloneProvider(); - const provider = capturedActiveProvider ?? standaloneProvider; - if (!provider) { - throw new Error('Cannot create standalone Hyperliquid provider'); - } - const { address } = evmAccount; - const { isTestnet, hip3ConfigVersion } = this.state; - const network = isTestnet ? 'testnet' : 'mainnet'; - const expectedDexes = this.#getStaticSnapshotDexes(); - if (!expectedDexes) { - throw new Error('User data snapshot DEX identity is not static'); - } - const isCurrent = (): boolean => { - let currentAddress: string | undefined; - try { - currentAddress = getSelectedEvmAccountFromMessenger( - this.messenger, - )?.address; - } catch { - return false; - } - - return ( - this.state.activeProvider === 'hyperliquid' && - (!capturedActiveProvider || - this.activeProviderInstance === capturedActiveProvider) && - this.state.isTestnet === isTestnet && - this.state.hip3ConfigVersion === hip3ConfigVersion && - currentAddress?.toLowerCase() === address.toLowerCase() - ); - }; - - const context: UserSnapshotContext = { - provider, - standaloneProvider, - address, - isTestnet, - hip3ConfigVersion, - expectedDexes, - isCurrent, - }; - const requestKey = [ - 'hyperliquid', - network, - address.toLowerCase(), - hip3ConfigVersion, - ...expectedDexes, - ].join('|'); - const existingRequest = this.#userSnapshotRequests.get(requestKey); - if (existingRequest?.provider === provider) { - return existingRequest.promise; - } - - const request = this.#fetchAndCacheUserDataSnapshot(context); - this.#userSnapshotRequests.set(requestKey, { provider, promise: request }); - try { - return await request; - } finally { - if (this.#userSnapshotRequests.get(requestKey)?.promise === request) { - this.#userSnapshotRequests.delete(requestKey); - } - } - } - - async #fetchAndCacheUserDataSnapshot( - context: UserSnapshotContext, - ): Promise { - const { - provider, - standaloneProvider, - address, - isTestnet, - hip3ConfigVersion, - expectedDexes, - isCurrent, - } = context; - if (!isCurrent()) { - throw new Error('User data snapshot context changed'); - } - if (!provider.getUserDataSnapshot) { - throw new Error('Provider has no atomic snapshot API'); - } - const identity = { - provider: 'hyperliquid' as const, - network: isTestnet ? ('testnet' as const) : ('mainnet' as const), - hip3ConfigVersion, - dexes: expectedDexes, - }; - const snapshotRequest = provider.getUserDataSnapshot({ - userAddress: address, - identity, - }); - const snapshot = standaloneProvider - ? await this.#trackStandaloneProviderOperation( - standaloneProvider, - snapshotRequest, - ) - : await snapshotRequest; - - if (!isCurrent()) { - throw new Error('User data snapshot context changed'); - } - - const snapshotIdentity = snapshot.identity; - const hasCompleteBundle = - Array.isArray(snapshot.positions) && - Array.isArray(snapshot.orders) && - snapshot.accountState !== null && - typeof snapshot.accountState === 'object'; - const hasExactIdentity = - snapshotIdentity.provider === identity.provider && - snapshotIdentity.network === identity.network && - snapshotIdentity.hip3ConfigVersion === identity.hip3ConfigVersion && - snapshotIdentity.address.toLowerCase() === address.toLowerCase() && - snapshotIdentity.dexes.length === expectedDexes.length && - snapshotIdentity.dexes.every( - (dex, index) => dex === expectedDexes[index], - ); - if (!hasCompleteBundle || !hasExactIdentity) { - throw new Error('User data snapshot is incomplete or mismatched'); - } - - if (!isCurrent()) { - throw new Error('User data snapshot context changed'); - } - - const cachedSnapshot = cloneUserDataSnapshot(snapshot); - const result = cloneUserDataSnapshot(snapshot); - const timestamp = Date.now(); - const providerNetworkKey = buildProviderCacheKey('hyperliquid', isTestnet); - this.update((state) => { - state.cachedUserDataByProvider[providerNetworkKey] = { - positions: cachedSnapshot.positions, - orders: cachedSnapshot.orders, - accountState: cachedSnapshot.accountState, - timestamp, - address, - hip3ConfigVersion, - dexes: expectedDexes, - }; - }); - this.#persistUserCacheToDisk(); - this.#debugLog('PerpsController: user cache snapshot written', { - writtenKey: providerNetworkKey, - availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), - positionCount: cachedSnapshot.positions.length, - orderCount: cachedSnapshot.orders.length, - }); - - return result; - } - /** * Returns a cached standalone HyperLiquidProvider for pre-initialization * discovery queries. Creates a new instance on first call or when the @@ -1717,13 +1403,10 @@ export class PerpsController extends BaseController< return this.#standaloneProvider; } - // Stale or missing — retire the old provider after active operations finish. + // Stale or missing — tear down old one (fire-and-forget) if (this.#standaloneProvider) { const old = this.#standaloneProvider; - this.#standaloneProvider = null; - this.#standaloneProviderIsTestnet = null; - this.#standaloneProviderHip3Version = null; - this.#retireStandaloneProvider(old).catch(() => { + Promise.resolve(old.disconnect()).catch(() => { /* best-effort */ }); } @@ -1742,12 +1425,6 @@ export class PerpsController extends BaseController< builderAddressMainnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressMainnet, - subscriptionBuilderAddressTestnet: - this.#options.clientConfig?.providerCredentials?.hyperliquid - ?.subscriptionBuilderAddressTestnet, - subscriptionBuilderAddressMainnet: - this.#options.clientConfig?.providerCredentials?.hyperliquid - ?.subscriptionBuilderAddressMainnet, }); this.#standaloneProviderIsTestnet = currentIsTestnet; this.#standaloneProviderHip3Version = currentHip3Version; @@ -1755,54 +1432,22 @@ export class PerpsController extends BaseController< return this.#standaloneProvider; } - #trackStandaloneProviderOperation( - provider: PerpsProvider, - operation: Promise, - ): Promise { - const operations = - this.#standaloneProviderOperations.get(provider) ?? new Set(); - this.#standaloneProviderOperations.set(provider, operations); - - const trackedOperation = operation.finally(() => { - operations.delete(trackedOperation); - if (operations.size === 0) { - this.#standaloneProviderOperations.delete(provider); - } - }); - operations.add(trackedOperation); - - return trackedOperation; - } - - async #retireStandaloneProvider( - provider: HyperLiquidProvider, - ): Promise { - const operations = this.#standaloneProviderOperations.get(provider); - if (operations?.size) { - await Promise.allSettled([...operations]); - } - try { - await provider.disconnect(); - } catch { - /* best-effort */ - } finally { - this.#standaloneProviderOperations.delete(provider); - } - } - /** * Disconnect and discard the cached standalone provider (if any). * Best-effort — errors are silently caught. */ async #cleanupStandaloneProvider(): Promise { - const provider = this.#standaloneProvider; - if (!provider) { + if (!this.#standaloneProvider) { return; } + try { + await this.#standaloneProvider.disconnect(); + } catch { + /* best-effort */ + } this.#standaloneProvider = null; this.#standaloneProviderIsTestnet = null; this.#standaloneProviderHip3Version = null; - await this.#retireStandaloneProvider(provider); } /** @@ -2230,12 +1875,6 @@ export class PerpsController extends BaseController< builderAddressMainnet: this.#options.clientConfig?.providerCredentials?.hyperliquid ?.builderAddressMainnet, - subscriptionBuilderAddressTestnet: - this.#options.clientConfig?.providerCredentials?.hyperliquid - ?.subscriptionBuilderAddressTestnet, - subscriptionBuilderAddressMainnet: - this.#options.clientConfig?.providerCredentials?.hyperliquid - ?.subscriptionBuilderAddressMainnet, }); this.providers.set('hyperliquid', hyperLiquidProvider); @@ -2522,11 +2161,16 @@ export class PerpsController extends BaseController< this.state.initializationState !== InitializationState.Initialized || !this.isInitialized ) { + const errorMessage = + this.state.initializationState === InitializationState.Failed + ? `${PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED}: ${this.state.initializationError ?? 'Initialization failed'}` + : PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED; + this.update((state) => { - state.lastError = PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED; + state.lastError = errorMessage; state.lastUpdateTimestamp = Date.now(); }); - throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); + throw new Error(errorMessage); } // Return the active provider instance (set during initialization based on providerMode) @@ -2541,22 +2185,6 @@ export class PerpsController extends BaseController< return this.activeProviderInstance; } - /** - * Await in-flight initialization, then return the active provider. - * Use for async action methods (trading, deposits, withdrawals) that should - * tolerate an in-progress cold-start or reconnection instead of failing - * immediately with CLIENT_NOT_INITIALIZED. - * - * Synchronous callers that need fail-fast behaviour should keep using - * getActiveProvider() directly. - * - * @returns The active provider once initialization completes. - */ - async #getActiveProviderWhenReady(): Promise { - await this.#awaitInitializationIfInProgress(); - return this.getActiveProvider(); - } - /** * Get the currently active provider, returning null if not available * Use this method when the caller can gracefully handle a missing provider @@ -2590,7 +2218,7 @@ export class PerpsController extends BaseController< * @returns The order result with order ID and status. */ async placeOrder(params: OrderParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.placeOrder({ @@ -2613,7 +2241,7 @@ export class PerpsController extends BaseController< * @returns The updated order result with order ID and status. */ async editOrder(params: EditOrderParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.editOrder({ @@ -2630,7 +2258,7 @@ export class PerpsController extends BaseController< * @returns The cancellation result with status. */ async cancelOrder(params: CancelOrderParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); return this.#tradingService.cancelOrder({ provider, @@ -2647,7 +2275,7 @@ export class PerpsController extends BaseController< * @returns The batch cancellation results for each order. */ async cancelOrders(params: CancelOrdersParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); return this.#tradingService.cancelOrders({ provider, @@ -2670,7 +2298,7 @@ export class PerpsController extends BaseController< * @returns The order result from the close position request. */ async closePosition(params: ClosePositionParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.closePosition({ @@ -2694,7 +2322,7 @@ export class PerpsController extends BaseController< async closePositions( params: ClosePositionsParams, ): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.closePositions({ @@ -2715,7 +2343,7 @@ export class PerpsController extends BaseController< async updatePositionTPSL( params: UpdatePositionTPSLParams, ): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.updatePositionTPSL({ @@ -2732,7 +2360,7 @@ export class PerpsController extends BaseController< * @returns The margin update result. */ async updateMargin(params: UpdateMarginParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.updateMargin({ @@ -2750,7 +2378,7 @@ export class PerpsController extends BaseController< * @returns The order result from the position flip. */ async flipPosition(params: FlipPositionParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); this.#ensureTradingServiceDeps(); return this.#tradingService.flipPosition({ @@ -2778,7 +2406,11 @@ export class PerpsController extends BaseController< let currentDepositId: string | undefined; try { - const provider = await this.#getActiveProviderWhenReady(); + // Clear any stale results when starting a new deposit flow + // Don't set depositInProgress yet - wait until user confirms + + // Prepare deposit transaction using DepositService + const provider = this.getActiveProvider(); const { transaction, assetChainId, @@ -3282,7 +2914,7 @@ export class PerpsController extends BaseController< * @returns WithdrawResult with withdrawal ID and tracking info */ async withdraw(params: WithdrawParams): Promise { - const provider = await this.#getActiveProviderWhenReady(); + const provider = this.getActiveProvider(); return this.#accountService.withdraw({ provider, @@ -3313,10 +2945,7 @@ export class PerpsController extends BaseController< // TODO: When adding new providers (MYX), consider a provider factory pattern const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - const operation = provider.getPositions(params); - return provider === this.#standaloneProvider - ? this.#trackStandaloneProviderOperation(provider, operation) - : operation; + return provider.getPositions(params); } const provider = this.getActiveProvider(); @@ -3388,10 +3017,7 @@ export class PerpsController extends BaseController< if (params?.standalone && params.userAddress) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - const operation = provider.getOpenOrders(params); - return provider === this.#standaloneProvider - ? this.#trackStandaloneProviderOperation(provider, operation) - : operation; + return provider.getOpenOrders(params); } const provider = this.getActiveProvider(); @@ -3443,10 +3069,7 @@ export class PerpsController extends BaseController< // Fallback to cached standalone provider for pre-initialization discovery const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - const operation = provider.getAccountState(params); - return provider === this.#standaloneProvider - ? this.#trackStandaloneProviderOperation(provider, operation) - : operation; + return provider.getAccountState(params); } const provider = this.getActiveProvider(); @@ -3490,15 +3113,12 @@ export class PerpsController extends BaseController< if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - const operation = this.#marketDataService.getMarkets({ + return this.#marketDataService.getMarkets({ provider, params, context: this.#createServiceContext('getMarkets'), isMarketAllowed, }); - return provider === this.#standaloneProvider - ? this.#trackStandaloneProviderOperation(provider, operation) - : operation; } const provider = this.getActiveProvider(); @@ -3528,99 +3148,24 @@ export class PerpsController extends BaseController< async getMarketDataWithPrices( params?: GetMarketDataWithPricesParams, ): Promise { - const globalSnapshot = this.#buildGlobalSnapshotContext(); - const context = this.#createServiceContext('getMarketDataWithPrices', { - ...(globalSnapshot && { globalSnapshot }), - }); if (params?.standalone) { const provider = this.activeProviderInstance ?? this.#getOrCreateStandaloneProvider(); - const operation = this.#marketDataService.getMarketDataWithPrices({ + return this.#marketDataService.getMarketDataWithPrices({ provider, params, - context, + context: this.#createServiceContext('getMarketDataWithPrices'), }); - return provider === this.#standaloneProvider - ? this.#trackStandaloneProviderOperation(provider, operation) - : operation; } const provider = this.getActiveProvider(); return this.#marketDataService.getMarketDataWithPrices({ provider, params, - context, + context: this.#createServiceContext('getMarketDataWithPrices'), }); } - /** - * Capture the exact static identity required to adopt an atomic snapshot. - * Dynamic DEX discovery and non-Hyperliquid provider modes deliberately opt - * out so they retain the provider path. - * - * @returns Snapshot identity plus a race guard, or undefined when unsafe. - */ - #buildGlobalSnapshotContext(): ServiceContext['globalSnapshot'] { - const snapshotConfigured = - Boolean(this.#options.infrastructure.terminalApi?.globalSnapshotUrl) || - typeof this.#options.infrastructure.terminalMarketService - ?.fetchGlobalSnapshot === 'function'; - if (!snapshotConfigured || this.state.activeProvider !== 'hyperliquid') { - return undefined; - } - - const enabledDexes = this.#getStaticSnapshotDexes(); - if (!enabledDexes) { - return undefined; - } - const { isTestnet, hip3ConfigVersion } = this.state; - return { - request: { - provider: 'hyperliquid', - network: isTestnet ? 'testnet' : 'mainnet', - enabledDexes, - }, - isCurrent: () => - this.state.activeProvider === 'hyperliquid' && - this.state.isTestnet === isTestnet && - this.state.hip3ConfigVersion === hip3ConfigVersion, - isMarketAllowed: this.#buildMarketAllowedFilter(), - }; - } - - #getStaticSnapshotDexes(): string[] | undefined { - if (!this.#hip3Enabled) { - return ['main']; - } - if (this.state.isTestnet) { - return TESTNET_HIP3_CONFIG.AutoDiscoverAll - ? undefined - : canonicalizeHyperLiquidDexes(TESTNET_HIP3_CONFIG.EnabledDexs); - } - if (MAINNET_HIP3_CONFIG.AutoDiscoverAll) { - return undefined; - } - - const dexes = new Set(); - for (const pattern of this.#hip3AllowlistMarkets) { - const colonIndex = pattern.indexOf(':'); - if (colonIndex <= 0) { - if (/^[a-z][a-z0-9]*$/iu.test(pattern)) { - dexes.add(pattern.toLowerCase()); - continue; - } - return undefined; - } - const dex = pattern.slice(0, colonIndex); - if (dex && /^[a-z0-9][a-z0-9-]*$/u.test(dex)) { - dexes.add(dex); - } else { - return undefined; - } - } - return canonicalizeHyperLiquidDexes(dexes); - } - // ============================================================================ // Market Data Preload (client-agnostic background caching) // ============================================================================ @@ -3635,17 +3180,8 @@ export class PerpsController extends BaseController< #isPreloading = false; - #marketPreloadQueued = false; - #isPreloadingUserData = false; - #userPreloadQueued = false; - - readonly #userSnapshotRequests = new Map< - string, - { provider: PerpsProvider; promise: Promise } - >(); - #preloadStateUnsubscribe: (() => void) | null = null; #accountChangeUnsubscribe: (() => void) | null = null; @@ -3694,47 +3230,6 @@ export class PerpsController extends BaseController< }); } - /** Persist the latest selected-account snapshot for each provider/network. */ - #persistUserCacheToDisk(): void { - const entries: DiskCacheUserEntry[] = []; - - for (const [cacheKey, entry] of Object.entries( - this.state.cachedUserDataByProvider, - )) { - const [providerId, network] = cacheKey.split(':'); - if ( - !providerId || - (network !== 'mainnet' && network !== 'testnet') || - providerId === 'aggregated' - ) { - continue; - } - entries.push({ - providerNetworkKey: `${providerId}:${network}`, - address: entry.address, - positions: entry.positions, - orders: entry.orders, - accountState: entry.accountState, - timestamp: entry.timestamp, - ...(entry.hip3ConfigVersion !== undefined && { - hip3ConfigVersion: entry.hip3ConfigVersion, - }), - ...(entry.dexes !== undefined && { dexes: entry.dexes }), - }); - } - - this.#userDiskWrite = this.#userDiskWrite - .then(() => - persistUserEntriesToDisk( - this.#options.infrastructure.diskCache, - entries, - ), - ) - .catch(() => { - // Disk persistence is best-effort and must not block live data. - }); - } - /** * Start background market data preloading. * Fetches market data immediately and refreshes every 5 minutes. @@ -3756,18 +3251,12 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); // Periodic refresh this.#preloadTimer = setInterval(() => { this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); }, PerpsController.#preloadRefreshMs); // Watch for isTestnet / hip3ConfigVersion changes @@ -3813,32 +3302,46 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); } }; - this.messenger.subscribe('PerpsController:stateChanged', handler); + this.messenger.subscribe('PerpsController:stateChange', handler); this.#preloadStateUnsubscribe = (): void => { - this.messenger.unsubscribe('PerpsController:stateChanged', handler); + this.messenger.unsubscribe('PerpsController:stateChange', handler); }; // Watch for selected account changes and selected account group changes. const accountChangeHandler = (): void => { const evmAccount = getSelectedEvmAccountFromMessenger(this.messenger); const currentAddress = evmAccount?.address ?? null; - this.#debugLog('PerpsController: account cache selection', { - address: currentAddress?.toLowerCase() ?? null, - availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), - }); - // The address guard makes the previous entry unreadable immediately; - // refresh replaces it under the existing provider/network key. - if (currentAddress) { - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ + // If any cached entry belongs to a different account, clear all entries. + // Max 4 entries (2 providers × 2 networks) — clearing all is simple and safe. + const hasStaleEntries = Object.values( + this.state.cachedUserDataByProvider, + ).some( + (entry) => + entry.address.toLowerCase() !== currentAddress?.toLowerCase(), + ); + if (hasStaleEntries) { + this.#debugLog( + 'PerpsController: Account changed, clearing user data cache', + ); + this.update((state) => { + state.cachedUserDataByProvider = {}; }); + // Invalidate disk-cached user data for the old account + this.#options.infrastructure.diskCache + .removeItem(PERPS_DISK_CACHE_USER_DATA) + .catch(() => { + /* fire-and-forget */ + }); + // Only preload if the new account is an EVM account + if (currentAddress) { + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); + } } }; this.messenger.subscribe( @@ -3880,8 +3383,6 @@ export class PerpsController extends BaseController< } this.#previousIsTestnet = null; this.#previousHip3ConfigVersion = null; - this.#marketPreloadQueued = false; - this.#userPreloadQueued = false; this.#cleanupStandaloneProvider().catch(() => { /* fire-and-forget to preserve sync signature */ }); @@ -3892,7 +3393,6 @@ export class PerpsController extends BaseController< */ async #performMarketDataPreload(): Promise { if (this.#isPreloading) { - this.#marketPreloadQueued = true; return; } @@ -3912,18 +3412,11 @@ export class PerpsController extends BaseController< actualProviderId, this.state.isTestnet, ); - const preloadContext = { - activeProvider: this.state.activeProvider, - isTestnet: this.state.isTestnet, - hip3ConfigVersion: this.state.hip3ConfigVersion, - }; - const staticSnapshotDexes = this.#getStaticSnapshotDexes(); const now = Date.now(); const existingEntry = this.state.cachedMarketDataByProvider[cacheKey]; if ( existingEntry && - this.#isMarketCacheEntryCurrent(actualProviderId, existingEntry) && now - existingEntry.timestamp < PerpsController.#preloadGuardMs ) { return; @@ -3955,41 +3448,8 @@ export class PerpsController extends BaseController< markets: data.length, }); - if ( - this.state.activeProvider !== preloadContext.activeProvider || - this.state.isTestnet !== preloadContext.isTestnet || - this.state.hip3ConfigVersion !== preloadContext.hip3ConfigVersion - ) { - traceData = { - success: false, - error: 'Global snapshot preload context changed', - }; - this.#debugLog( - 'PerpsController: Discarding stale global snapshot preload', - ); - return; - } - // Store under per-provider key(s) const ts = Date.now(); - const sourceExpiries = data.flatMap((market) => - market.dataSource === 'terminal-global-snapshot-mark' && - typeof market.sourceExpiresAt === 'number' - ? [market.sourceExpiresAt] - : [], - ); - const sourceExpiresAt = - data.length > 0 && sourceExpiries.length === data.length - ? Math.min(...sourceExpiries) - : undefined; - const snapshotCacheIdentity = - sourceExpiresAt !== undefined && staticSnapshotDexes - ? { - sourceExpiresAt, - hip3ConfigVersion: preloadContext.hip3ConfigVersion, - dexes: staticSnapshotDexes, - } - : {}; const marketDiskEntries: { providerNetworkKey: string; data: PerpsMarketData[]; @@ -4040,7 +3500,6 @@ export class PerpsController extends BaseController< state.cachedMarketDataByProvider[cacheKey] = { data, timestamp: ts, - ...snapshotCacheIdentity, }; }); } @@ -4060,8 +3519,12 @@ export class PerpsController extends BaseController< PerpsMeasurementName.PerpsMarketDataPreload, performance.now() - preloadStart, 'millisecond', - traceId, ); + + // Also preload user data (fire-and-forget, non-blocking) + this.#performUserDataPreload().catch(() => { + /* fire-and-forget */ + }); } catch (error) { traceData = { success: false, @@ -4081,12 +3544,6 @@ export class PerpsController extends BaseController< data: traceData, }); this.#isPreloading = false; - if (this.#marketPreloadQueued && this.#preloadTimer) { - this.#marketPreloadQueued = false; - this.#performMarketDataPreload().catch(() => { - // Background preload is best-effort. - }); - } } } @@ -4096,11 +3553,6 @@ export class PerpsController extends BaseController< */ async #performUserDataPreload(): Promise { if (this.#isPreloadingUserData) { - this.#userPreloadQueued = true; - return; - } - - if (this.#isReinitializing) { return; } @@ -4111,73 +3563,33 @@ export class PerpsController extends BaseController< } const userAddress = evmAccount.address; - const { activeProvider, isTestnet, hip3ConfigVersion } = this.state; - const { activeProviderInstance } = this; - const hyperliquidDexes = this.#getStaticSnapshotDexes(); - const isCurrent = (): boolean => { - let currentAddress: string | undefined; - try { - currentAddress = getSelectedEvmAccountFromMessenger( - this.messenger, - )?.address; - } catch { - return false; - } - return ( - this.state.activeProvider === activeProvider && - this.activeProviderInstance === activeProviderInstance && - this.state.isTestnet === isTestnet && - this.state.hip3ConfigVersion === hip3ConfigVersion && - currentAddress?.toLowerCase() === userAddress.toLowerCase() - ); - }; // Determine actual provider (same logic as market preload) - const actualProviderId = activeProviderInstance - ? activeProvider // includes 'aggregated' + const actualProviderId = this.activeProviderInstance + ? this.state.activeProvider // includes 'aggregated' : 'hyperliquid'; - const providerNetworkKey = buildProviderCacheKey( + const userCacheKey = buildProviderCacheKey( actualProviderId, - isTestnet, + this.state.isTestnet, ); // Skip if cache is fresh and for same account const now = Date.now(); - const existingEntry = - this.state.cachedUserDataByProvider[providerNetworkKey]; - const hasMatchingCache = - existingEntry !== undefined && - this.#isUserCacheIdentityCurrent( - actualProviderId, - existingEntry, - userAddress, - ); - const cacheAgeMs = existingEntry ? now - existingEntry.timestamp : null; - const websocketState = this.getWebSocketConnectionState(); - let selectedEntryKey: string | null = null; - if (this.state.cachedUserDataByProvider[providerNetworkKey]) { - selectedEntryKey = providerNetworkKey; - } - this.#debugLog('PerpsController: user cache preload decision', { - requestedKey: providerNetworkKey, - selectedEntryKey, - availableKeys: Object.keys(this.state.cachedUserDataByProvider).sort(), - hasMatchingCache, - cacheAgeMs, - websocketState, - }); + const existingEntry = this.state.cachedUserDataByProvider[userCacheKey]; if ( - existingEntry && - hasMatchingCache && + existingEntry?.address === userAddress && now - existingEntry.timestamp < PerpsController.#preloadGuardMs ) { return; } + // Skip standalone REST polling when WebSocket is connected — live data is streaming if ( - hasMatchingCache && this.getWebSocketConnectionState() === WebSocketConnectionState.Connected ) { + this.#debugLog( + 'PerpsController: Skipping user data preload — WebSocket connected', + ); return; } @@ -4199,33 +3611,15 @@ export class PerpsController extends BaseController< id: traceId, op: PerpsTraceOperations.Operation, tags: { - provider: activeProvider, - isTestnet, + provider: this.state.activeProvider, + isTestnet: this.state.isTestnet, }, + data: { userAddress }, }); - this.#debugLog('PerpsController: Fetching user data in background'); - - if (activeProvider === 'hyperliquid') { - const snapshot = await this.getUserDataSnapshot(); - this.#debugLog('PerpsController: User data preloaded', { - positionCount: snapshot.positions.length, - orderCount: snapshot.orders.length, - totalBalance: snapshot.accountState.totalBalance, - }); - traceData = { - success: true, - positionCount: snapshot.positions.length, - orderCount: snapshot.orders.length, - }; - this.#options.infrastructure.tracer.setMeasurement( - PerpsMeasurementName.PerpsUserDataPreload, - performance.now() - preloadStart, - 'millisecond', - traceId, - ); - return; - } + this.#debugLog('PerpsController: Fetching user data in background', { + userAddress, + }); const [positions, orders, accountState] = await Promise.all([ this.getPositions({ standalone: true, userAddress }), @@ -4233,11 +3627,10 @@ export class PerpsController extends BaseController< this.getAccountState({ standalone: true, userAddress }), ]); - if (!isCurrent()) { - throw new Error('User data preload context changed'); - } - - if (activeProvider === 'aggregated' && activeProviderInstance) { + if ( + this.state.activeProvider === 'aggregated' && + this.activeProviderInstance + ) { // Split by providerId and write one cache entry per provider key // (mirrors the market-data preload pattern at ~line 2976) const ts = Date.now(); @@ -4275,22 +3668,33 @@ export class PerpsController extends BaseController< accountState.providerId ?? fallbackProviderId, ).accountState = accountState; + const diskEntries: { + providerNetworkKey: string; + address: string; + positions: Position[]; + orders: Order[]; + accountState: AccountState | null; + timestamp: number; + }[] = []; this.update((state) => { for (const [pid, data] of byProvider) { - const key = buildProviderCacheKey(pid, isTestnet); + const key = buildProviderCacheKey(pid, this.state.isTestnet); + diskEntries.push({ + providerNetworkKey: key, + address: userAddress, + positions: data.positions, + orders: data.orders, + accountState: data.accountState, + timestamp: ts, + }); state.cachedUserDataByProvider[key] = { ...data, timestamp: ts, address: userAddress, - ...(pid === 'hyperliquid' && - hyperliquidDexes && { - hip3ConfigVersion, - dexes: hyperliquidDexes, - }), }; } // Write aggregated sentinel so the staleness guard sees it - state.cachedUserDataByProvider[providerNetworkKey] = { + state.cachedUserDataByProvider[userCacheKey] = { positions: [], orders: [], accountState: null, @@ -4299,26 +3703,33 @@ export class PerpsController extends BaseController< }; }); - this.#persistUserCacheToDisk(); + persistUserEntriesToDisk( + this.#options.infrastructure.diskCache, + diskEntries, + ); } else { // Single provider — store directly under its key const ts = Date.now(); this.update((state) => { - state.cachedUserDataByProvider[providerNetworkKey] = { + state.cachedUserDataByProvider[userCacheKey] = { positions, orders, accountState, timestamp: ts, address: userAddress, - ...(actualProviderId === 'hyperliquid' && - hyperliquidDexes && { - hip3ConfigVersion, - dexes: hyperliquidDexes, - }), }; }); - this.#persistUserCacheToDisk(); + persistUserEntriesToDisk(this.#options.infrastructure.diskCache, [ + { + providerNetworkKey: userCacheKey, + address: userAddress, + positions, + orders, + accountState, + timestamp: ts, + }, + ]); } this.#debugLog('PerpsController: User data preloaded', { @@ -4337,7 +3748,6 @@ export class PerpsController extends BaseController< PerpsMeasurementName.PerpsUserDataPreload, performance.now() - preloadStart, 'millisecond', - traceId, ); } catch (error) { traceData = { @@ -4358,12 +3768,6 @@ export class PerpsController extends BaseController< data: traceData, }); this.#isPreloadingUserData = false; - if (this.#userPreloadQueued && this.#preloadTimer) { - this.#userPreloadQueued = false; - this.#performUserDataPreload().catch(() => { - // Background preload is best-effort. - }); - } } } @@ -4671,9 +4075,6 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); } } } @@ -4814,9 +4215,6 @@ export class PerpsController extends BaseController< this.#performMarketDataPreload().catch(() => { /* fire-and-forget */ }); - this.#performUserDataPreload().catch(() => { - /* fire-and-forget */ - }); } } } @@ -5194,46 +4592,10 @@ export class PerpsController extends BaseController< params: FeeCalculationParams, ): Promise { const provider = this.getActiveProvider(); - // Preview owns subscription hydration. The submit resolver remains a pure - // cache read and can therefore never start a benefits request while an - // order is being signed. - await this.#rewardsIntegrationService.refreshSubscriptionBenefits(); - const waiverStatus = - this.#rewardsIntegrationService.getSubscriptionFeeWaiverStatus(); - const context = this.#createServiceContext('calculateFees', { - subscriptionFeeWaiver: - waiverStatus.reason === 'no-source' ? undefined : waiverStatus, - }); + const context = this.#createServiceContext('calculateFees'); return this.#marketDataService.calculateFees({ provider, params, context }); } - /** - * Approve the dedicated subscription builder outside order submission. - * Until this succeeds, subscription waivers fall back to the ordinary - * builder at the standard fee. - * - * @returns Whether the subscription builder is approved. - */ - async approveSubscriptionBuilderFee(): Promise { - const provider = this.getActiveProvider(); - return provider.approveSubscriptionBuilderFee - ? provider.approveSubscriptionBuilderFee() - : false; - } - - /** - * Drop the cached subscription benefits snapshot. - * - * Call this when the identity behind the benefits changes — sign-out, or a - * profile switch. The snapshot carries no profile identity of its own, so - * without this it keeps answering for the previous profile until the next - * successful refresh. The next fee resolution reports the waiver as - * unavailable, so it is withheld until preview or lifecycle hydration. - */ - invalidateSubscriptionBenefits(): void { - this.#rewardsIntegrationService.invalidateSubscriptionBenefits(); - } - /** * Disconnect provider and cleanup subscriptions * Call this when navigating away from Perps screens to prevent battery drain diff --git a/packages/perps-controller/src/constants/eventNames.ts b/packages/perps-controller/src/constants/eventNames.ts index ff119c8dd44..611448468a9 100644 --- a/packages/perps-controller/src/constants/eventNames.ts +++ b/packages/perps-controller/src/constants/eventNames.ts @@ -18,8 +18,6 @@ export const PERPS_EVENT_PROPERTY = { // Trade properties LEVERAGE: 'leverage', LEVERAGE_USED: 'leverage_used', - // Perp UI Interaction `leverage_changed`: prior leverage before the user change - PREVIOUS_LEVERAGE: 'previous_leverage', ORDER_SIZE: 'order_size', MARGIN_USED: 'margin_used', ORDER_TYPE: 'order_type', // lowercase per dashboard @@ -233,13 +231,9 @@ export const PERPS_EVENT_PROPERTY = { SEARCH_QUERY: 'search_query', RESULTS_COUNT: 'results_count', RESULT_RANK: 'result_rank', - // Search intent (`discovery` / `intent` / `browse`) — not Lite/Pro UI mode MODE: 'mode', CURRENT_TOKEN: 'current_token', - // Lite/Pro interface mode (`'lite' | 'pro'`) - PERPS_MODE: 'perps_mode', - // Sort / filter properties SORT_FIELD: 'sort_field', SORT_DIRECTION: 'sort_direction', @@ -444,10 +438,6 @@ export const PERPS_EVENT_VALUE = { ORDER_TYPE_SELECTED: 'order_type_selected', /** @deprecated Use LEVERAGE_CHANGED instead for clarity */ SETTING_CHANGED: 'setting_changed', - /** - * Perp UI Interaction `leverage_changed`. Properties include `leverage` - * and `previous_leverage`. - */ LEVERAGE_CHANGED: 'leverage_changed', TUTORIAL_STARTED: 'tutorial_started', TUTORIAL_COMPLETED: 'tutorial_completed', diff --git a/packages/perps-controller/src/constants/hyperLiquidConfig.ts b/packages/perps-controller/src/constants/hyperLiquidConfig.ts index a23f61a070f..5a6ecdac9ec 100644 --- a/packages/perps-controller/src/constants/hyperLiquidConfig.ts +++ b/packages/perps-controller/src/constants/hyperLiquidConfig.ts @@ -27,20 +27,6 @@ export const HYPERLIQUID_MAINNET_CAIP_CHAIN_ID = 'eip155:999' as CaipChainId; export const HYPERLIQUID_TESTNET_CAIP_CHAIN_ID = 'eip155:998' as CaipChainId; export const HYPERLIQUID_NETWORK_NAME = 'Hyperliquid'; -/** - * Return the canonical snapshot identity: main first, then unique DEX ids. - * - * @param dexes - DEX identifiers to canonicalize. - * @returns The canonical DEX identifiers. - */ -export function canonicalizeHyperLiquidDexes( - dexes: Iterable, -): string[] { - const additionalDexes = new Set(dexes); - additionalDexes.delete('main'); - return ['main', ...Array.from(additionalDexes).sort()]; -} - // Token constants export const USDC_SYMBOL = 'USDC'; export const USDC_NAME = 'USD Coin'; diff --git a/packages/perps-controller/src/constants/perpsConfig.ts b/packages/perps-controller/src/constants/perpsConfig.ts index f81dac88963..8ea127d834e 100644 --- a/packages/perps-controller/src/constants/perpsConfig.ts +++ b/packages/perps-controller/src/constants/perpsConfig.ts @@ -384,25 +384,10 @@ export const DATA_LAKE_API_CONFIG = { OrdersEndpoint: 'https://perps.api.cx.metamask.io/api/v1/orders', } as const; -/** - * Subscription benefits cache (stale-while-revalidate). - * - * The unified fee resolver never awaits the benefits read, so these bounds are - * what decide whether the cached snapshot may grant the perps fee waiver: - * - within `FreshMs` the snapshot is served as-is, - * - past `FreshMs` it is still served while a background refresh runs, - * - past `MaxStaleMs` it is no longer trusted to grant the waiver, and the - * resolver falls back to the next-lowest fee source. - */ -export const SUBSCRIPTION_BENEFITS_CACHE = { - FreshMs: 60_000, // 1 minute – no refresh triggered - MaxStaleMs: 10 * 60 * 1000, // 10 minutes – ceiling for granting the waiver -} as const; - /** * Terminal API configuration. * The full endpoint URL is injected at runtime via - * `PerpsPlatformDependencies.terminalApi.marketDataUrl` from each client build + * `PerpsPlatformDependencies.terminalApiUrl` from each client build * (dev/uat/prd); only cache settings live here. */ export const TERMINAL_API_CONFIG = { @@ -502,10 +487,9 @@ export enum PerpsMode { } /** - * Side filter for the Pro Positions list (long/short/all). + * Side filter for the Pro Positions/Orders panel (long/short/all). * - * Independent of `ordersSideFilter`. Shared across markets via - * `proLayoutPreferences.positionsSideFilter`. + * Shared across markets via `proLayoutPreferences.positionsSideFilter`. */ export type ProPositionsSideFilter = 'all' | 'long' | 'short'; @@ -522,32 +506,15 @@ export type ProPositionsSortField = */ export type ProPositionsSortDirection = 'asc' | 'desc'; -/** - * Side filter for the Pro Orders list (long/short/all). - * - * Independent of `positionsSideFilter`. Shared across markets via - * `proLayoutPreferences.ordersSideFilter`. - */ -export type ProOrdersSideFilter = 'all' | 'long' | 'short'; - -/** - * Sort fields available on the Pro Orders list. - */ -export type ProOrdersSortField = 'orderValue' | 'size' | 'price' | 'time'; - -/** - * Sort direction for the Pro Orders list. - */ -export type ProOrdersSortDirection = 'asc' | 'desc'; - /** * Pro-mode layout preferences (network-independent). * * Flat object that persists across markets (unlike the per-market * `tradeConfigurations`). `chartExpanded` and the `*Position` fields are - * reserved for future container-position UI. Positions and Orders each have - * their own side filter and sort so they survive market navigation and app - * restarts independently. + * reserved for future container-position UI. `positionsSideFilter` / + * `positionsSortField` / `positionsSortDirection` back the Positions/Orders + * panel sort and side filter so they survive market navigation and app + * restarts. */ export type ProLayoutPreferences = { orderBookExpanded: boolean; @@ -557,9 +524,6 @@ export type ProLayoutPreferences = { positionsSideFilter: ProPositionsSideFilter; positionsSortField: ProPositionsSortField; positionsSortDirection: ProPositionsSortDirection; - ordersSideFilter: ProOrdersSideFilter; - ordersSortField: ProOrdersSortField; - ordersSortDirection: ProOrdersSortDirection; }; /** @@ -577,9 +541,6 @@ export const DEFAULT_PRO_LAYOUT_PREFERENCES: ProLayoutPreferences = { positionsSideFilter: 'all', positionsSortField: 'positionValue', positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', }; /** diff --git a/packages/perps-controller/src/index.ts b/packages/perps-controller/src/index.ts index 578b60b3521..d19405776ea 100644 --- a/packages/perps-controller/src/index.ts +++ b/packages/perps-controller/src/index.ts @@ -44,15 +44,11 @@ export type { PerpsControllerActions, PerpsControllerEvents, ProLayoutPreferences, - ProOrdersSideFilter, - ProOrdersSortDirection, - ProOrdersSortField, ProPositionsSideFilter, ProPositionsSortDirection, ProPositionsSortField, } from './PerpsController.js'; export type { - PerpsControllerApproveSubscriptionBuilderFeeAction, PerpsControllerCalculateFeesAction, PerpsControllerCalculateLiquidationPriceAction, PerpsControllerCalculateMaintenanceMarginAction, @@ -80,7 +76,6 @@ export type { PerpsControllerGetBlockExplorerUrlAction, PerpsControllerGetCachedMarketDataForActiveProviderAction, PerpsControllerGetCachedUserDataForActiveProviderAction, - PerpsControllerGetUserDataSnapshotAction, PerpsControllerGetCurrentNetworkAction, PerpsControllerGetFundingAction, PerpsControllerGetHistoricalPortfolioAction, @@ -102,7 +97,6 @@ export type { PerpsControllerGetWithdrawalProgressAction, PerpsControllerGetWithdrawalRoutesAction, PerpsControllerInitAction, - PerpsControllerInvalidateSubscriptionBenefitsAction, PerpsControllerIsCurrentlyReinitializingAction, PerpsControllerIsFirstTimeUserOnCurrentNetworkAction, PerpsControllerIsWatchlistMarketAction, @@ -222,8 +216,6 @@ export type { CheckEligibilityParams, GetPositionsParams, GetAccountStateParams, - GetUserDataSnapshotParams, - PerpsUserDataSnapshot, GetOrderFillsParams, GetOrFetchFillsParams, GetOrdersParams, @@ -248,11 +240,6 @@ export type { MaintenanceMarginParams, FeeCalculationParams, FeeCalculationResult, - PerpsSubscriptionBenefits, - PerpsSubscriptionUsage, - PerpsSubscriptionFeeWaiverStatus, - PerpsFeeSource, - PerpsFeeResolution, UpdatePositionTPSLParams, Order, Funding, @@ -281,8 +268,6 @@ export type { PerpsRemoteFeatureFlagState, PerpsPlatformDependencies, PerpsTerminalMarketService, - PerpsGlobalSnapshotRequest, - PerpsGlobalSnapshotResult, TerminalAssetMetadata, PerpsCacheType, InvalidateCacheParams, diff --git a/packages/perps-controller/src/perpsErrorCodes.ts b/packages/perps-controller/src/perpsErrorCodes.ts index 7861bcdea84..be6204b39c2 100644 --- a/packages/perps-controller/src/perpsErrorCodes.ts +++ b/packages/perps-controller/src/perpsErrorCodes.ts @@ -66,7 +66,7 @@ export const PERPS_ERROR_CODES = { ORDER_TWAP_DURATION_REQUIRED: 'ORDER_TWAP_DURATION_REQUIRED', // TWAP placed without twapDuration ORDER_TWAP_DURATION_INVALID: 'ORDER_TWAP_DURATION_INVALID', // twapDuration not a whole number of minutes within the venue's bounds ORDER_SCALE_RANGE_REQUIRED: 'ORDER_SCALE_RANGE_REQUIRED', // Scale placed without both ladder bounds - ORDER_SCALE_RANGE_INVALID: 'ORDER_SCALE_RANGE_INVALID', // Scale ladder bounds or skew are invalid + ORDER_SCALE_RANGE_INVALID: 'ORDER_SCALE_RANGE_INVALID', // Scale ladder bounds non-positive or inverted ORDER_SCALE_COUNT_INVALID: 'ORDER_SCALE_COUNT_INVALID', // scaleNumOrders missing, non-integer, or outside the supported ladder size ORDER_SCALE_SIZE_TOO_SMALL: 'ORDER_SCALE_SIZE_TOO_SMALL', // Total size cannot give every ladder rung a non-zero slice ORDER_SCALE_NOTIONAL_TOO_SMALL: 'ORDER_SCALE_NOTIONAL_TOO_SMALL', // Ladder notional split across the rungs leaves each below the venue's per-order minimum diff --git a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts index 22a84842ee8..3add88a3cec 100644 --- a/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts +++ b/packages/perps-controller/src/providers/AggregatedPerpsProvider.ts @@ -80,7 +80,6 @@ import type { WithdrawResult, RawLedgerUpdate, PerpsReadOptions, - PerpsFeeResolution, } from '../types/index.js'; /** @@ -657,24 +656,6 @@ export class AggregatedPerpsProvider implements PerpsProvider { }); } - setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { - this.#providers.forEach((provider) => { - if (provider.setUserFeeResolution) { - provider.setUserFeeResolution(resolution); - } else if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(resolution?.discountBips); - } - }); - } - - async approveSubscriptionBuilderFee(): Promise { - const provider = - this.#providers.get('hyperliquid') ?? this.#getDefaultProvider(); - return provider.approveSubscriptionBuilderFee - ? provider.approveSubscriptionBuilderFee() - : false; - } - // ============================================================================ // Lifecycle (Delegate to default provider) // ============================================================================ diff --git a/packages/perps-controller/src/providers/HyperLiquidProvider.ts b/packages/perps-controller/src/providers/HyperLiquidProvider.ts index eec51719dae..495b89dec95 100644 --- a/packages/perps-controller/src/providers/HyperLiquidProvider.ts +++ b/packages/perps-controller/src/providers/HyperLiquidProvider.ts @@ -14,7 +14,6 @@ import { import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, - canonicalizeHyperLiquidDexes, FEE_RATES, getBridgeInfo, getChainId, @@ -91,7 +90,6 @@ import type { GetOrFetchFillsParams, GetPositionsParams, GetSupportedPathsParams, - GetUserDataSnapshotParams, HistoricalPortfolioResult, InitializeResult, PerpsPlatformDependencies, @@ -127,8 +125,6 @@ import type { WithdrawResult, RawLedgerUpdate, PerpsReadOptions, - PerpsUserDataSnapshot, - PerpsFeeResolution, } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import type { OrderType, StrategyOrderType } from '../types/perps-types.js'; @@ -195,7 +191,6 @@ import { isLimitExecutionOrderType, isStrategyOrderType, isTriggerOrderType, - resolvePositionTriggerSummaryPrice, toSDKTimeInForce, } from '../utils/orderTypes.js'; import { @@ -323,7 +318,6 @@ const pickStrategyParams = ( scaleMinPrice: params.scaleMinPrice, scaleMaxPrice: params.scaleMaxPrice, scaleNumOrders: params.scaleNumOrders, - scaleSkew: params.scaleSkew, chaseIntervalMs: params.chaseIntervalMs, chaseMaxDurationMs: params.chaseMaxDurationMs, chaseMaxRepricings: params.chaseMaxRepricings, @@ -542,8 +536,6 @@ type ChaseSession = { * would otherwise be re-quoted at the undiscounted maximum. */ builderFee: number; - /** Builder address captured with the fee for attribution across re-prices. */ - builderAddress: string; /** Absolute deadline, as a `Date.now()` stamp. */ deadline: number; maxRepricings: number; @@ -617,43 +609,12 @@ function collectPositionTriggerOrders(params: { }): { takeProfitOrders: PositionTriggerOrder[]; stopLossOrders: PositionTriggerOrder[]; - takeProfitPrice?: string; - stopLossPrice?: string; } { const { orders, position, childOrderIds } = params; const byOrderId = new Map(); - let takeProfitPrice: string | undefined; - let stopLossPrice: string | undefined; orders.forEach((rawOrder) => { - if ( - rawOrder.isTrigger && - rawOrder.reduceOnly && - rawOrder.isPositionTpsl === Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) - ) { - if (rawOrder.orderType.includes('Take Profit')) { - takeProfitPrice = rawOrder.triggerPx; - } else if (rawOrder.orderType.includes('Stop')) { - stopLossPrice = rawOrder.triggerPx; - } - } - - rawOrder.children?.forEach((childOrder) => { - if ( - !childOrder.isTrigger || - !childOrder.reduceOnly || - childOrder.isPositionTpsl !== Boolean(TP_SL_CONFIG.UsePositionBoundTpsl) - ) { - return; - } - if (childOrder.orderType.includes('Take Profit')) { - takeProfitPrice = childOrder.triggerPx; - } else if (childOrder.orderType.includes('Stop')) { - stopLossPrice = childOrder.triggerPx; - } - }); - if ( rawOrder.coin !== position.symbol || !rawOrder.isTrigger || @@ -675,27 +636,14 @@ function collectPositionTriggerOrders(params: { }); const triggerOrders = Array.from(byOrderId.values()); - const takeProfitOrders = triggerOrders.filter( - (order) => order.direction === 'take_profit', - ); - const stopLossOrders = triggerOrders.filter( - (order) => order.direction !== 'take_profit', - ); - - const takeProfitSummaryPrice = resolvePositionTriggerSummaryPrice({ - triggerOrders: takeProfitOrders, - scannedPrice: takeProfitPrice, - }); - const stopLossSummaryPrice = resolvePositionTriggerSummaryPrice({ - triggerOrders: stopLossOrders, - scannedPrice: stopLossPrice, - }); return { - takeProfitOrders, - stopLossOrders, - ...(takeProfitSummaryPrice && { takeProfitPrice: takeProfitSummaryPrice }), - ...(stopLossSummaryPrice && { stopLossPrice: stopLossSummaryPrice }), + takeProfitOrders: triggerOrders.filter( + (order) => order.direction === 'take_profit', + ), + stopLossOrders: triggerOrders.filter( + (order) => order.direction !== 'take_profit', + ), }; } @@ -775,11 +723,6 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #pendingBuilderFeeApprovals = new Map>(); - #subscriptionBuilderApprovalEpoch = 0; - - /** Builder approvals keyed by network, account, and builder address. */ - readonly #approvedBuilderAddresses = new Set(); - // Pre-compiled patterns for fast filtering readonly #compiledAllowlistPatterns: CompiledMarketPattern[] = []; @@ -788,8 +731,6 @@ export class HyperLiquidProvider implements PerpsProvider { // Fee discount context for MetaMask reward discounts (in basis points) #userFeeDiscountBips?: number; - #userFeeResolution?: PerpsFeeResolution; - // Feature flag configuration for HIP-3 market filtering readonly #hip3Enabled: boolean; @@ -865,10 +806,6 @@ export class HyperLiquidProvider implements PerpsProvider { readonly #builderAddressMainnet?: string; - readonly #subscriptionBuilderAddressTestnet?: string; - - readonly #subscriptionBuilderAddressMainnet?: string; - readonly #priceDeviationLimit: number; constructor(options: { @@ -883,17 +820,11 @@ export class HyperLiquidProvider implements PerpsProvider { initialAssetMapping?: [string, number][]; builderAddressTestnet?: string; builderAddressMainnet?: string; - subscriptionBuilderAddressTestnet?: string; - subscriptionBuilderAddressMainnet?: string; }) { this.#deps = options.platformDependencies; this.#messenger = options.messenger; this.#builderAddressTestnet = options.builderAddressTestnet; this.#builderAddressMainnet = options.builderAddressMainnet; - this.#subscriptionBuilderAddressTestnet = - options.subscriptionBuilderAddressTestnet; - this.#subscriptionBuilderAddressMainnet = - options.subscriptionBuilderAddressMainnet; this.#priceDeviationLimit = options.priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; @@ -932,11 +863,6 @@ export class HyperLiquidProvider implements PerpsProvider { this.#allowlistMarkets, this.#blocklistMarkets, this.#priceDeviationLimit, - async () => { - await this.#ensureClientsInitialized(); - const validatedDexs = await this.#getValidatedDexs(); - return validatedDexs.filter((dex): dex is string => dex !== null); - }, ); // NOTE: Clients are NOT initialized here - they'll be initialized lazily @@ -1800,7 +1726,7 @@ export class HyperLiquidProvider implements PerpsProvider { 'Price cache miss for getOrFetchPrice, falling back to REST allMids', { symbol }, ); - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const mids = await infoClient.allMids( dexName ? { dex: dexName } : undefined, ); @@ -2061,16 +1987,8 @@ export class HyperLiquidProvider implements PerpsProvider { } } - // Cache miss or skipCache=true - fetch from API. - // Bring the SDK clients up first. This is the first client touch on the - // write path — placeOrder resolves asset info before it ensures trading - // readiness — so without it a cold start or a post-disconnect action fails - // with CLIENT_NOT_INITIALIZED instead of waiting for the clients it needs. - // Idempotent, and a warm cache hit returns above without reaching here. - await this.#ensureClientsInitialized(); - // Metadata is request/response data, so keep this path available while a - // failed WebSocket reconnect is retrying. - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + // Cache miss or skipCache=true - fetch from API + const infoClient = this.#clientService.getInfoClient(); // Pass dex only for HIP-3 DEXs; omit for main DEX (empty string). // Testnet API returns null when dex="" is explicitly sent. const meta = await infoClient.meta(dexKey ? { dex: dexKey } : undefined); @@ -2387,14 +2305,6 @@ export class HyperLiquidProvider implements PerpsProvider { return `${network}:${userAddress.toLowerCase()}`; } - #getApprovedBuilderKey( - network: string, - userAddress: string, - builderAddress: string, - ): string { - return `${this.#getCacheKey(network, userAddress)}:${builderAddress.toLowerCase()}`; - } - /** * Fetch markets for a specific DEX with optional filtering * Uses session-based caching via getCachedMeta() - no TTL, cleared on disconnect @@ -2723,7 +2633,6 @@ export class HyperLiquidProvider implements PerpsProvider { * @param discountBips - The discount in basis points (e.g., 550 = 5.5%) */ setUserFeeDiscount(discountBips: number | undefined): void { - this.#userFeeResolution = undefined; this.#userFeeDiscountBips = discountBips; this.#deps.debugLogger.log('HyperLiquid: Fee discount context updated', { @@ -2733,22 +2642,6 @@ export class HyperLiquidProvider implements PerpsProvider { }); } - /** - * Set the resolved fee and its attribution source for the next operation. - * - * @param resolution - Unified fee resolution, or undefined to clear it. - */ - setUserFeeResolution(resolution: PerpsFeeResolution | undefined): void { - this.#userFeeResolution = resolution; - this.#userFeeDiscountBips = resolution?.discountBips; - - this.#deps.debugLogger.log('HyperLiquid: Fee resolution context updated', { - source: resolution?.source, - discountBips: resolution?.discountBips, - isActive: resolution !== undefined, - }); - } - /** * Query user data across all enabled DEXs in parallel * @@ -2945,15 +2838,14 @@ export class HyperLiquidProvider implements PerpsProvider { /** * Check current builder fee approval for the user * - * @param builder - Builder address to query. - * @param userAddress - Account whose approval should be queried. * @returns Current max fee rate or null if not approved */ - async #checkBuilderFeeApproval( - builder: string, - userAddress: string, - ): Promise { + async #checkBuilderFeeApproval(): Promise { const infoClient = this.#clientService.getInfoClient(); + const userAddress = await this.#walletService.getUserAddressWithDefault(); + const builder = this.#getBuilderAddress( + this.#clientService.isTestnetMode(), + ); return infoClient.maxBuilderFee({ user: userAddress, @@ -2988,9 +2880,6 @@ export class HyperLiquidProvider implements PerpsProvider { { network, success: globalCached.success }, ); this.#builderFeeCheckCache.set(cacheKey, true); - this.#approvedBuilderAddresses.add( - this.#getApprovedBuilderKey(network, userAddress, builderAddress), - ); return; } @@ -3031,10 +2920,8 @@ export class HyperLiquidProvider implements PerpsProvider { return; } - const { isApproved, requiredDecimal } = await this.#checkBuilderFeeStatus( - builderAddress, - userAddress, - ); + const { isApproved, requiredDecimal } = + await this.#checkBuilderFeeStatus(); if (isApproved) { // User already has approval on-chain @@ -3043,9 +2930,6 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }); this.#builderFeeCheckCache.set(cacheKey, true); - this.#approvedBuilderAddresses.add( - this.#getApprovedBuilderKey(network, userAddress, builderAddress), - ); this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Already approved on-chain', @@ -3066,10 +2950,7 @@ export class HyperLiquidProvider implements PerpsProvider { }); // Verify approval was successful before caching - const afterApprovalDecimal = await this.#checkBuilderFeeApproval( - builderAddress, - userAddress, - ); + const afterApprovalDecimal = await this.#checkBuilderFeeApproval(); if ( afterApprovalDecimal === null || @@ -3086,9 +2967,6 @@ export class HyperLiquidProvider implements PerpsProvider { success: true, }); this.#builderFeeCheckCache.set(cacheKey, true); - this.#approvedBuilderAddresses.add( - this.#getApprovedBuilderKey(network, userAddress, builderAddress), - ); this.#deps.debugLogger.log( '[ensureBuilderFeeApproval] Approval successful', @@ -3132,127 +3010,17 @@ export class HyperLiquidProvider implements PerpsProvider { } } - /** - * Approve the dedicated subscription builder outside order submission. - * Failure is non-blocking: order construction will use the ordinary builder - * at the standard fee until a later approval succeeds. - * - * @returns Whether the builder is approved for the current account. - */ - async approveSubscriptionBuilderFee(): Promise { - const approvalEpoch = this.#subscriptionBuilderApprovalEpoch; - await this.#ensureClientsInitialized(); - if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { - return false; - } - const isTestnet = this.#clientService.isTestnetMode(); - const network = isTestnet ? 'testnet' : 'mainnet'; - const builderAddress = this.#getSubscriptionBuilderAddress(isTestnet); - if (!builderAddress) { - return false; - } - const userAddress = await this.#walletService.getUserAddressWithDefault(); - const key = this.#getApprovedBuilderKey( - network, - userAddress, - builderAddress, - ); - if (this.#approvedBuilderAddresses.has(key)) { - return true; - } - - const pending = this.#pendingBuilderFeeApprovals.get(key); - if (pending) { - try { - await pending; - return this.#approvedBuilderAddresses.has(key); - } catch (error) { - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Subscription builder approval unavailable', - error, - ); - return false; - } - } - - const approval = (async (): Promise => { - const currentApproval = await this.#checkBuilderFeeApproval( - builderAddress, - userAddress, - ); - if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { - return; - } - if ( - currentApproval !== null && - currentApproval >= BUILDER_FEE_CONFIG.MaxFeeDecimal - ) { - this.#approvedBuilderAddresses.add(key); - return; - } - - const exchangeClient = this.#clientService.getExchangeClient(); - await exchangeClient.approveBuilderFee({ - builder: builderAddress, - maxFeeRate: BUILDER_FEE_CONFIG.MaxFeeRate, - }); - if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { - return; - } - const afterApproval = await this.#checkBuilderFeeApproval( - builderAddress, - userAddress, - ); - if (approvalEpoch !== this.#subscriptionBuilderApprovalEpoch) { - return; - } - if ( - afterApproval === null || - afterApproval < BUILDER_FEE_CONFIG.MaxFeeDecimal - ) { - throw new Error( - '[HyperLiquidProvider] Subscription builder approval verification failed', - ); - } - this.#approvedBuilderAddresses.add(key); - })(); - this.#pendingBuilderFeeApprovals.set(key, approval); - - try { - await approval; - return this.#approvedBuilderAddresses.has(key); - } catch (error) { - this.#deps.debugLogger.log( - 'HyperLiquidProvider: Subscription builder approval unavailable', - error, - ); - return false; - } finally { - if (this.#pendingBuilderFeeApprovals.get(key) === approval) { - this.#pendingBuilderFeeApprovals.delete(key); - } - } - } - /** * Check if builder fee is approved for the current user * - * @param builderAddress - Builder address to query. - * @param userAddress - Account whose approval should be queried. * @returns Object with approval status and current rate */ - async #checkBuilderFeeStatus( - builderAddress: string, - userAddress: string, - ): Promise<{ + async #checkBuilderFeeStatus(): Promise<{ isApproved: boolean; currentRate: number | null; requiredDecimal: number; }> { - const currentApproval = await this.#checkBuilderFeeApproval( - builderAddress, - userAddress, - ); + const currentApproval = await this.#checkBuilderFeeApproval(); const requiredDecimal = BUILDER_FEE_CONFIG.MaxFeeDecimal; return { @@ -3995,7 +3763,18 @@ export class HyperLiquidProvider implements PerpsProvider { const exchangeClient = this.#clientService.getExchangeClient(); - const builder = await this.#getBuilderOrderContext(); + // Calculate discounted builder fee + let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; + if (this.#userFeeDiscountBips !== undefined) { + builderFee = Math.floor( + builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + this.#deps.debugLogger.log('Applying builder fee discount', { + originalFee: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + discountBips: this.#userFeeDiscountBips, + discountedFee: builderFee, + }); + } this.#deps.debugLogger.log('Submitting order via asset ID routing', { symbol, @@ -4010,7 +3789,10 @@ export class HyperLiquidProvider implements PerpsProvider { const result = await exchangeClient.order({ orders, grouping, - builder, + builder: { + b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + f: builderFee, + }, }); if (result.status !== 'ok') { @@ -4581,9 +4363,8 @@ export class HyperLiquidProvider implements PerpsProvider { * venue-formatted price, which would submit several orders at one price * instead of a ladder spanning the requested range. * - **Slices are not the average.** `splitScaleSizes` floors onto the size - * grid, and a `scaleSkew` weights the rungs along the ladder on top of that, - * so no rung carries the average slice the pre-network check in - * `validateOrder` approximates with. + * grid and puts the remainder on the first rung, so no rung carries the + * average slice the pre-network check in `validateOrder` approximates with. * - **The cheapest rung decides.** Each rung is an independent order, so the * venue applies its per-order minimum to the smallest slice at the lowest * price, not to the ladder's total. @@ -4612,13 +4393,11 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); } - // Throws ORDER_SCALE_SIZE_TOO_SMALL when a rung would round to nothing, - // which a skew weighted far enough from even can do on its own. + // Throws ORDER_SCALE_SIZE_TOO_SMALL when a rung would round to nothing. const sizes = splitScaleSizes({ totalSize: finalPositionSize, count, szDecimals, - skew: params.scaleSkew, }); const minimumOrderSize = this.#getMinimumOrderSize(); @@ -4740,7 +4519,10 @@ export class HyperLiquidProvider implements PerpsProvider { const result = await exchangeClient.order({ orders, grouping: 'na', - builder: await this.#getBuilderOrderContext(), + builder: { + b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + f: this.#getDiscountedBuilderFee(), + }, }); if (result.status !== 'ok') { @@ -4847,8 +4629,8 @@ export class HyperLiquidProvider implements PerpsProvider { throw new Error(PERPS_ERROR_CODES.ORDER_CHASE_ABANDONED); } - // Read once, while the caller's fee-source context is still set. - const builder = await this.#getBuilderOrderContext(); + // Read once, while the caller's discount context is still set. + const builderFee = this.#getDiscountedBuilderFee(); // Held onto rather than looked up again after the submission returns. // `disconnect` drops the service's client reference synchronously, so a @@ -4863,8 +4645,7 @@ export class HyperLiquidProvider implements PerpsProvider { price: quotePrice, size: formattedSize, reduceOnly: params.reduceOnly ?? false, - builderFee: builder.f, - builderAddress: builder.b, + builderFee, exchangeClient: placingClient, }); @@ -4882,8 +4663,7 @@ export class HyperLiquidProvider implements PerpsProvider { pendingReplacement: null, restingPrice: quotePrice, intervalMs, - builderFee: builder.f, - builderAddress: builder.b, + builderFee, deadline: Date.now() + (params.chaseMaxDurationMs ?? CHASE_ORDER_CONFIG.DefaultMaxDurationMs), @@ -5014,7 +4794,6 @@ export class HyperLiquidProvider implements PerpsProvider { * @param params.reduceOnly - Whether the order may only reduce a position. * @param params.builderFee - Builder fee, in tenths of a basis point, captured * when the session started so replacements keep the rate they were quoted at. - * @param params.builderAddress - Builder address captured with the fee. * @param params.exchangeClient - Client to submit through. Passed in rather * than looked up here so a first placement can keep the instance it signed * with, which is the only one that can take the order back once `disconnect` @@ -5028,7 +4807,6 @@ export class HyperLiquidProvider implements PerpsProvider { size: string; reduceOnly: boolean; builderFee: number; - builderAddress: string; exchangeClient: ExchangeClient; }): Promise { const result = await params.exchangeClient.order({ @@ -5046,7 +4824,7 @@ export class HyperLiquidProvider implements PerpsProvider { ], grouping: 'na', builder: { - b: params.builderAddress, + b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), f: params.builderFee, }, }); @@ -5273,7 +5051,6 @@ export class HyperLiquidProvider implements PerpsProvider { size: remaining, reduceOnly: session.reduceOnly, builderFee: session.builderFee, - builderAddress: session.builderAddress, // A running session is on a live provider, so the current client is // the right one; only the first placement has a teardown to survive. exchangeClient: this.#clientService.getExchangeClient(), @@ -5759,46 +5536,6 @@ export class HyperLiquidProvider implements PerpsProvider { ); } - /** - * Resolve the builder payload for the current operation. - * - * Subscription waivers use their dedicated builder only after approval is - * cached for this provider/account session. Until then, the ordinary builder - * and standard fee keep the trade attributable and non-blocking. - * - * @returns HyperLiquid builder address and fee payload. - */ - async #getBuilderOrderContext(): Promise<{ b: string; f: number }> { - const isTestnet = this.#clientService.isTestnetMode(); - const network = isTestnet ? 'testnet' : 'mainnet'; - const defaultBuilder = this.#getBuilderAddress(isTestnet); - - if (this.#userFeeResolution?.source === 'subscription') { - const subscriptionBuilder = - this.#getSubscriptionBuilderAddress(isTestnet); - const userAddress = await this.#walletService.getUserAddressWithDefault(); - if ( - subscriptionBuilder && - this.#approvedBuilderAddresses.has( - this.#getApprovedBuilderKey( - network, - userAddress, - subscriptionBuilder, - ), - ) - ) { - return { b: subscriptionBuilder, f: 0 }; - } - - return { - b: defaultBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }; - } - - return { b: defaultBuilder, f: this.#getDiscountedBuilderFee() }; - } - /** * Read the account's currently resting orders. * @@ -6480,11 +6217,22 @@ export class HyperLiquidProvider implements PerpsProvider { }; } + // Calculate discounted builder fee if reward discount is active + let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; + if (this.#userFeeDiscountBips !== undefined) { + builderFee = Math.floor( + builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + } + // Single batch API call const result = await exchangeClient.order({ orders, grouping: 'na', - builder: await this.#getBuilderOrderContext(), + builder: { + b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + f: builderFee, + }, }); // Parse response statuses (one per order) @@ -6958,13 +6706,32 @@ export class HyperLiquidProvider implements PerpsProvider { }; } + // Calculate discounted builder fee if reward discount is active + let builderFee = BUILDER_FEE_CONFIG.MaxFeeTenthsBps; + if (this.#userFeeDiscountBips !== undefined) { + builderFee = Math.floor( + builderFee * (1 - this.#userFeeDiscountBips / BASIS_POINTS_DIVISOR), + ); + this.#deps.debugLogger.log( + 'HyperLiquid: Applying builder fee discount to TP/SL', + { + originalFee: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, + discountBips: this.#userFeeDiscountBips, + discountedFee: builderFee, + }, + ); + } + // Submit via SDK exchange client. Position-bound TP/SL uses 'positionTpsl'; // partial TP/SL must be standalone reduce-only triggers ('na'), since a // position-bound TP/SL always closes the whole position. const result = await exchangeClient.order({ orders, grouping: isPartialTpsl ? 'na' : 'positionTpsl', - builder: await this.#getBuilderOrderContext(), + builder: { + b: this.#getBuilderAddress(this.#clientService.isTestnetMode()), + f: builderFee, + }, }); if (result.status !== 'ok') { @@ -7381,181 +7148,6 @@ export class HyperLiquidProvider implements PerpsProvider { return state.validated; } - /** - * Fetch a complete standalone user-data bundle. - * - * Each DEX clearinghouse response is shared by position and account-state - * mapping. Any required request failure rejects the entire bundle. - * - * @param params - User and captured controller identity. - * @returns The complete user-data snapshot. - */ - async getUserDataSnapshot( - params: GetUserDataSnapshotParams, - ): Promise { - const { identity, userAddress } = params; - const network = this.#clientService.isTestnetMode() ? 'testnet' : 'mainnet'; - const snapshotStartedAt = this.#deps.performance.now(); - const measure = async ( - stage: string, - request: () => Promise, - dex?: string | null, - ): Promise => { - const startedAt = this.#deps.performance.now(); - const dexDetail = dex === undefined ? {} : { dex: dex ?? 'main' }; - try { - const result = await request(); - this.#deps.debugLogger.log('[PerpsUserSnapshot]', { - stage, - durationMs: Math.round(this.#deps.performance.now() - startedAt), - success: true, - ...dexDetail, - }); - return result; - } catch (error) { - this.#deps.debugLogger.log('[PerpsUserSnapshot]', { - stage, - durationMs: Math.round(this.#deps.performance.now() - startedAt), - success: false, - ...dexDetail, - }); - throw error; - } - }; - - if (identity.provider !== 'hyperliquid' || identity.network !== network) { - throw new Error('User data snapshot identity does not match provider'); - } - - const requestedDexes = identity.dexes; - const canonicalDexes = canonicalizeHyperLiquidDexes(requestedDexes); - const hasValidDexIdentity = - requestedDexes.length > 0 && - new Set(requestedDexes).size === requestedDexes.length && - requestedDexes.every( - (dex) => dex === 'main' || /^[a-z0-9][a-z0-9-]*$/u.test(dex), - ) && - requestedDexes.length === canonicalDexes.length && - requestedDexes.every((dex, index) => dex === canonicalDexes[index]); - if (!hasValidDexIdentity) { - throw new Error('User data snapshot DEX identity is invalid'); - } - const dexs = requestedDexes.map((dex) => (dex === 'main' ? null : dex)); - const standaloneInfoClient = createStandaloneInfoClient({ - isTestnet: network === 'testnet', - }); - const buildUserParams = ( - dex: string | null, - ): { user: string; dex?: string } => ({ - user: userAddress, - ...(dex ? { dex } : {}), - }); - - const [clearinghouseStates, openOrdersByDex, spotState, abstractionMode] = - await Promise.all([ - Promise.all( - dexs.map((dex) => - measure( - 'clearinghouse_state', - () => - standaloneInfoClient.clearinghouseState(buildUserParams(dex)), - dex, - ), - ), - ), - Promise.all( - dexs.map((dex) => - measure( - 'frontend_open_orders', - () => - standaloneInfoClient.frontendOpenOrders(buildUserParams(dex)), - dex, - ), - ), - ), - measure('spot_clearinghouse_state', () => - standaloneInfoClient.spotClearinghouseState({ user: userAddress }), - ), - measure('user_abstraction', () => - standaloneInfoClient.userAbstraction({ user: userAddress }), - ), - ]); - - const rawOrders = openOrdersByDex.flat(); - const childOrderIds = collectChildOrderIds(rawOrders); - const ordersBySymbol = groupOrdersBySymbol(rawOrders); - const positions = clearinghouseStates.flatMap((state) => - state.assetPositions - .filter(({ position }) => position.szi !== '0') - .map((assetPosition) => { - const position = adaptPositionFromSDK(assetPosition); - const { - takeProfitOrders, - stopLossOrders, - takeProfitPrice, - stopLossPrice, - } = collectPositionTriggerOrders({ - orders: ordersBySymbol.get(position.symbol) ?? [], - position, - childOrderIds, - }); - return { - ...position, - takeProfitCount: takeProfitOrders.length, - stopLossCount: stopLossOrders.length, - takeProfitOrders, - stopLossOrders, - ...(takeProfitPrice && { takeProfitPrice }), - ...(stopLossPrice && { stopLossPrice }), - }; - }), - ); - const positionsBySymbol = new Map( - positions.map((position) => [position.symbol, position]), - ); - const orders = rawOrders.map((order) => - adaptOrderFromSDK(order, positionsBySymbol.get(order.coin)), - ); - const dexAccountStates = clearinghouseStates.map((state) => - adaptAccountStateFromSDK(state), - ); - const accountState = addSpotBalanceToAccountState( - aggregateAccountStates(dexAccountStates), - spotState, - { foldIntoCollateral: hyperLiquidModeFoldsSpot(abstractionMode) }, - ); - - accountState.subAccountBreakdown = Object.fromEntries( - dexAccountStates.map((dexAccountState, index) => { - return [ - dexs[index] ?? '', - { - spendableBalance: dexAccountState.spendableBalance, - withdrawableBalance: dexAccountState.withdrawableBalance, - totalBalance: dexAccountState.totalBalance, - }, - ]; - }), - ); - - const snapshot = { - positions, - orders, - accountState, - identity: { - ...identity, - address: userAddress, - }, - }; - this.#deps.debugLogger.log('[PerpsUserSnapshot]', { - stage: 'complete', - durationMs: Math.round(this.#deps.performance.now() - snapshotStartedAt), - success: true, - dexCount: dexs.length, - }); - return snapshot; - } - /** * Query one DEX's positions directly, preserving whether that DEX answered. * @@ -7867,14 +7459,8 @@ export class HyperLiquidProvider implements PerpsProvider { return { ...position, - takeProfitPrice: resolvePositionTriggerSummaryPrice({ - triggerOrders: takeProfitOrders, - scannedPrice: takeProfitPrice, - }), - stopLossPrice: resolvePositionTriggerSummaryPrice({ - triggerOrders: stopLossOrders, - scannedPrice: stopLossPrice, - }), + takeProfitPrice, + stopLossPrice, takeProfitCount: takeProfitOrders.length, stopLossCount: stopLossOrders.length, takeProfitOrders, @@ -10995,10 +10581,6 @@ export class HyperLiquidProvider implements PerpsProvider { // Clear session caches (ensures fresh state on reconnect/account switch) this.#referralCheckCache.clear(); this.#builderFeeCheckCache.clear(); - this.#subscriptionBuilderApprovalEpoch += 1; - this.#approvedBuilderAddresses.clear(); - this.#userFeeResolution = undefined; - this.#userFeeDiscountBips = undefined; // NOTE: UnifiedAccountCache is global and NOT cleared on disconnect // to prevent repeated signing requests across reconnections this.#cachedMetaByDex.clear(); @@ -11234,12 +10816,6 @@ export class HyperLiquidProvider implements PerpsProvider { return this.#builderAddressMainnet || BUILDER_FEE_CONFIG.MainnetBuilder; } - #getSubscriptionBuilderAddress(isTestnet: boolean): string | undefined { - return isTestnet - ? this.#subscriptionBuilderAddressTestnet - : this.#subscriptionBuilderAddressMainnet; - } - #getReferralCode(isTestnet: boolean): string { return isTestnet ? REFERRAL_CONFIG.TestnetCode diff --git a/packages/perps-controller/src/services/HyperLiquidClientService.ts b/packages/perps-controller/src/services/HyperLiquidClientService.ts index efb380d053d..e9aeacdb6d0 100644 --- a/packages/perps-controller/src/services/HyperLiquidClientService.ts +++ b/packages/perps-controller/src/services/HyperLiquidClientService.ts @@ -84,8 +84,6 @@ export class HyperLiquidClientService { #httpTransport?: HttpTransport; - #walletParams?: HyperLiquidWalletParams; - #isTestnet: boolean; #connectionState: WebSocketConnectionState = @@ -136,7 +134,6 @@ export class HyperLiquidClientService { try { this.#updateConnectionState(WebSocketConnectionState.Connecting); - this.#walletParams = wallet; this.#createTransports(); // Ensure transports are created @@ -144,7 +141,23 @@ export class HyperLiquidClientService { throw new Error('Failed to create transports'); } - this.#createAllClients(wallet); + // Wallet adapter implements AbstractViemJsonRpcAccount interface with signTypedData method + // ExchangeClient uses HTTP transport for write operations (orders, approvals, etc.) + this.#exchangeClient = new ExchangeClient({ + wallet: wallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility + transport: this.#httpTransport, + }); + + // InfoClient with WebSocket transport (default) - multiplexed requests over single connection + this.#infoClient = new InfoClient({ transport: this.#wsTransport }); + + // InfoClient with HTTP transport (fallback) - for specific calls if WebSocket has issues + this.#infoClientHttp = new InfoClient({ transport: this.#httpTransport }); + + // SubscriptionClient uses WebSocket transport for real-time pub/sub (price feeds, position updates) + this.#subscriptionClient = new SubscriptionClient({ + transport: this.#wsTransport, + }); // Wait for WebSocket to actually be ready before setting CONNECTED // This ensures we have a real connection, not just client objects @@ -279,48 +292,6 @@ export class HyperLiquidClientService { return this.#wsTransport; } - /** - * Create all SDK clients using the current transports. - * Shared by initialize() and #handleConnectionDrop() to avoid drift. - * - * @param wallet - Optional wallet params. Uses stored #walletParams when omitted (reconnection path). - */ - #createAllClients(wallet?: HyperLiquidWalletParams): void { - if (!this.#wsTransport || !this.#httpTransport) { - throw new Error('Transports must be created before clients'); - } - - this.#infoClient = new InfoClient({ transport: this.#wsTransport }); - this.#subscriptionClient = new SubscriptionClient({ - transport: this.#wsTransport, - }); - this.#createHttpClients(wallet); - } - - /** - * Create the HTTP-backed SDK clients. - * - * @param wallet - Optional wallet params. Uses stored #walletParams when omitted. - */ - #createHttpClients(wallet?: HyperLiquidWalletParams): void { - const effectiveWallet = wallet ?? this.#walletParams; - - if (!this.#httpTransport) { - throw new Error('HTTP transport must be created before clients'); - } - - this.#infoClientHttp = new InfoClient({ transport: this.#httpTransport }); - - if (effectiveWallet) { - this.#exchangeClient = new ExchangeClient({ - wallet: effectiveWallet as any, // eslint-disable-line @typescript-eslint/no-explicit-any -- Type widening for SDK compatibility - transport: this.#httpTransport, - }); - } else { - this.#exchangeClient = undefined; - } - } - /** * Toggle testnet mode and reinitialize clients * @@ -367,26 +338,10 @@ export class HyperLiquidClientService { wallet: HyperLiquidWalletParams, ): Promise { if (!this.#subscriptionClient) { - // A reconnect publishes its WebSocket clients only after transport.ready(). - // Do not start a competing initialize() while that attempt or its retry - // backoff is active; callers will observe an unavailable subscription - // client until the reconnect completes and restores tracked subscriptions. - if (this.#isReconnecting || this.#reconnectionRetryTimeout) { - return; - } - this.#deps.debugLogger.log( 'HyperLiquid: Recreating subscription client after disconnect', ); - - if ( - this.#walletParams && - this.#connectionState === WebSocketConnectionState.Disconnected - ) { - await this.reconnect(); - } else { - await this.initialize(wallet); - } + await this.initialize(wallet); } } @@ -396,8 +351,8 @@ export class HyperLiquidClientService { * @returns The initialized ExchangeClient instance. */ public getExchangeClient(): ExchangeClient { + this.ensureInitialized(); if (!this.#exchangeClient) { - this.ensureInitialized(); throw new Error(PERPS_ERROR_CODES.EXCHANGE_CLIENT_NOT_AVAILABLE); } return this.#exchangeClient; @@ -411,15 +366,15 @@ export class HyperLiquidClientService { * @returns InfoClient instance with the selected transport. */ public getInfoClient(options?: { useHttp?: boolean }): InfoClient { + this.ensureInitialized(); + if (options?.useHttp) { if (!this.#infoClientHttp) { - this.ensureInitialized(); throw new Error(PERPS_ERROR_CODES.INFO_CLIENT_NOT_AVAILABLE); } return this.#infoClientHttp; } - this.ensureInitialized(); if (!this.#infoClient) { throw new Error(PERPS_ERROR_CODES.INFO_CLIENT_NOT_AVAILABLE); } @@ -532,6 +487,7 @@ export class HyperLiquidClientService { signal?: AbortSignal; }): Promise { const { symbol, interval, limit = 100, endTime, signal } = options; + this.ensureInitialized(); if (signal?.aborted) { const abortError = new Error('Aborted'); @@ -1236,28 +1192,17 @@ export class HyperLiquidClientService { this.#wsTransport = undefined; this.#httpTransport = undefined; - // WebSocket clients are unavailable throughout the reconnect. HTTP - // clients remain usable while the new socket is staged and verified. - this.#subscriptionClient = undefined; - this.#infoClient = undefined; - - // Recreate transports (both WS and HTTP) + // Recreate WebSocket transport - returns the new transport for type safety const newWsTransport = this.#createTransports(); - const newInfoClient = new InfoClient({ transport: newWsTransport }); - const newSubscriptionClient = new SubscriptionClient({ + // Recreate clients that use WebSocket transport + this.#infoClient = new InfoClient({ transport: newWsTransport }); + this.#subscriptionClient = new SubscriptionClient({ transport: newWsTransport, }); - this.#createHttpClients(); await newWsTransport.ready(); - // Publish WebSocket clients only after the transport is usable. This - // keeps isInitialized() false and blocks WS-backed access during a - // failed or in-flight reconnect without disabling HTTP-backed trading. - this.#infoClient = newInfoClient; - this.#subscriptionClient = newSubscriptionClient; - this.#deps.debugLogger.log( 'HyperLiquid: Transport ready, restoring subscriptions', { timestamp: new Date().toISOString() }, @@ -1277,21 +1222,6 @@ export class HyperLiquidClientService { this.#updateConnectionState(WebSocketConnectionState.Connected); this.#isReconnecting = false; } catch { - // The staged WebSocket clients were never published. Keep the HTTP - // clients alive so exchange writes and explicit HTTP info reads remain - // available while the WebSocket retry loop continues. - this.#subscriptionClient = undefined; - this.#infoClient = undefined; - - if (this.#wsTransport) { - try { - this.#wsTransport.close(); - } catch { - // Ignore cleanup errors - transport may already be dead - } - } - this.#wsTransport = undefined; - // Reset flag before scheduling retry so the next attempt can proceed this.#isReconnecting = false; diff --git a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts index d3916924b34..a034ba1b780 100644 --- a/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts +++ b/packages/perps-controller/src/services/HyperLiquidSubscriptionService.ts @@ -26,6 +26,7 @@ import { import type { SpotClearinghouseStateResponse, HyperLiquidAbstractionMode, + UserAbstractionResponse, } from '../types/hyperliquid-types.js'; import { hyperLiquidModeFoldsSpot } from '../types/hyperliquid-types.js'; import { WebSocketConnectionState } from '../types/index.js'; @@ -68,7 +69,6 @@ import { import { buildPositionTriggerOrderFromOrder, hashTriggerOrders, - resolvePositionTriggerSummaryPrice, } from '../utils/orderTypes.js'; import type { HyperLiquidClientService } from './HyperLiquidClientService.js'; import type { HyperLiquidWalletService } from './HyperLiquidWalletService.js'; @@ -106,8 +106,6 @@ export class HyperLiquidSubscriptionService { // Max market-vs-oracle price deviation before a market is reported untradable readonly #priceDeviationLimit: number; - readonly #discoverEnabledDexs?: () => Promise; - #discoveredDexNames: string[] = []; // DEX order for mapping webData3 perpDexStates indices // DEX discovery synchronization - allows subscriptions to wait for HIP-3 DEX discovery @@ -387,7 +385,6 @@ export class HyperLiquidSubscriptionService { allowlistMarkets?: string[], blocklistMarkets?: string[], priceDeviationLimit?: number, - discoverEnabledDexs?: () => Promise, ) { this.#clientService = clientService; this.#walletService = walletService; @@ -399,7 +396,6 @@ export class HyperLiquidSubscriptionService { this.#blocklistMarkets = blocklistMarkets ?? []; this.#priceDeviationLimit = priceDeviationLimit ?? HYPERLIQUID_CONFIG.OraclePriceDeviationLimit; - this.#discoverEnabledDexs = discoverEnabledDexs; } /** @@ -646,16 +642,6 @@ export class HyperLiquidSubscriptionService { }); } - const discovery = this.#discoverEnabledDexs - ? this.#discoverEnabledDexs() - .then((enabledDexs) => { - this.#enabledDexs = enabledDexs; - this.#discoveredDexNames = enabledDexs; - return undefined; - }) - .catch(() => this.#dexDiscoveryPromise ?? Promise.resolve()) - : this.#dexDiscoveryPromise; - // Wait with timeout let timeoutId: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_resolve, reject) => { @@ -666,7 +652,7 @@ export class HyperLiquidSubscriptionService { }); try { - await Promise.race([discovery, timeoutPromise]); + await Promise.race([this.#dexDiscoveryPromise, timeoutPromise]); } catch { this.#deps.debugLogger.log( 'DEX discovery wait timed out, proceeding with main DEX only', @@ -1183,16 +1169,8 @@ export class HyperLiquidSubscriptionService { return { ...position, - // The scanned prices only ever come from position-bound triggers, so a - // lone quantity-scoped trigger has to be read off the array instead. - takeProfitPrice: resolvePositionTriggerSummaryPrice({ - triggerOrders: takeProfitOrders, - scannedPrice: tpsl.takeProfitPrice, - }), - stopLossPrice: resolvePositionTriggerSummaryPrice({ - triggerOrders: stopLossOrders, - scannedPrice: tpsl.stopLossPrice, - }), + takeProfitPrice: tpsl.takeProfitPrice ?? undefined, + stopLossPrice: tpsl.stopLossPrice ?? undefined, // Counts come from the same arrays as the REST path, so both transports // report one definition. Orders whose placement type the exchange did // not name (HyperLiquid's ambiguous 'Trigger') are absent from both, @@ -1494,7 +1472,7 @@ export class HyperLiquidSubscriptionService { // independent of the spot generation, and the post-fetch path below // correctly handles the generation-changed case (seal + re-aggregate // instead of overwriting WS spot). - const infoClient = this.#clientService.getInfoClient({ useHttp: true }); + const infoClient = this.#clientService.getInfoClient(); const lowerUserAddress = userAddress.toLowerCase(); // Fetch spot state + abstraction mode in parallel — mode decides // whether the spot fold applies in addSpotBalanceToAccountState. diff --git a/packages/perps-controller/src/services/MarketDataService.ts b/packages/perps-controller/src/services/MarketDataService.ts index df8d9758aeb..83721cfcfec 100644 --- a/packages/perps-controller/src/services/MarketDataService.ts +++ b/packages/perps-controller/src/services/MarketDataService.ts @@ -875,8 +875,8 @@ export class MarketDataService { /** * Get market data with prices (includes price, volume, 24h change). * Applies optional category filtering, sorting, and limit after fetching. - * An explicitly configured global snapshot is the preferred complete source. - * `useTerminalApi` controls only legacy metadata enrichment of provider data. + * When `useTerminalApi` is true, enriches provider data with Terminal API metadata + * (name, keywords, tags, categories). On Terminal API failure, falls back silently. * * @param options - The configuration options. * @param options.provider - The perps provider instance. @@ -890,7 +890,6 @@ export class MarketDataService { context: ServiceContext; }): Promise { const { provider, params, context } = options; - const { globalSnapshot } = context; const useTerminalApi = params?.useTerminalApi; const traceId = uuidv4(); let traceData: { success: boolean; error?: string } | undefined; @@ -912,57 +911,11 @@ export class MarketDataService { }, }); - // Prefer a separately configured atomic snapshot only for an exact, - // still-current provider/network/DEX identity. A rejected snapshot has - // one lexical fallback to the provider below and is not followed by a - // second legacy Terminal request. - let snapshotAttempted = false; - if ( - globalSnapshot && - this.#deps.terminalMarketService?.fetchGlobalSnapshot - ) { - snapshotAttempted = true; - if (!globalSnapshot.isCurrent()) { - throw new Error('Terminal global snapshot context changed'); - } - try { - const snapshot = - await this.#deps.terminalMarketService.fetchGlobalSnapshot( - globalSnapshot.request, - ); - if (!globalSnapshot.isCurrent()) { - throw new Error('Terminal global snapshot context changed'); - } - if (Date.now() >= snapshot.expiresAt) { - throw new Error('Terminal global snapshot expired'); - } - if (snapshot.markets.length > 0) { - traceData = { success: true }; - const allowedMarkets = snapshot.markets.filter((market) => - globalSnapshot.isMarketAllowed(market.symbol), - ); - return applyMarketFilters(allowedMarkets, params); - } - } catch (snapshotError) { - if (!globalSnapshot.isCurrent()) { - throw new Error('Terminal global snapshot context changed'); - } - this.#deps.terminalMarketService.logError( - snapshotError, - 'getMarketDataWithPrices.globalSnapshot', - ); - } - } - // Fetch Terminal API metadata before provider data when enabled. // Terminal metadata enriches the provider result (name, keywords, tags, // categories) but never replaces live pricing / funding data. let terminalMetadata: Map | undefined; - if ( - !snapshotAttempted && - useTerminalApi && - this.#deps.terminalMarketService - ) { + if (useTerminalApi && this.#deps.terminalMarketService) { try { const result = await this.#deps.terminalMarketService.fetchMarkets(); if (result.metadata.size > 0) { @@ -977,9 +930,6 @@ export class MarketDataService { } const markets = await provider.getMarketDataWithPrices(); - if (snapshotAttempted && globalSnapshot && !globalSnapshot.isCurrent()) { - throw new Error('Terminal global snapshot context changed'); - } // Enrich with terminal metadata when available const enriched = terminalMetadata @@ -1286,17 +1236,10 @@ export class MarketDataService { params: FeeCalculationParams; context: ServiceContext; }): Promise { - const { provider, params, context } = options; + const { provider, params } = options; try { - const fees = await provider.calculateFees(params); - - // Read-only preview of the same cached benefits snapshot the fee resolver - // reads. The quoted rates are left untouched: surfacing eligibility and - // the remaining notional must not mutate the cap or the cache. - return context.subscriptionFeeWaiver - ? { ...fees, subscription: context.subscriptionFeeWaiver } - : fees; + return await provider.calculateFees(params); } catch (error) { this.#deps.logger.error( ensureError(error, 'MarketDataService.calculateFees'), diff --git a/packages/perps-controller/src/services/RewardsIntegrationService.ts b/packages/perps-controller/src/services/RewardsIntegrationService.ts index 228e70bbc8c..cdaca4f9d4d 100644 --- a/packages/perps-controller/src/services/RewardsIntegrationService.ts +++ b/packages/perps-controller/src/services/RewardsIntegrationService.ts @@ -2,59 +2,18 @@ import { BASIS_POINTS_DIVISOR, BUILDER_FEE_CONFIG, } from '../constants/hyperLiquidConfig.js'; -import { - PERPS_CONSTANTS, - SUBSCRIPTION_BENEFITS_CACHE, -} from '../constants/perpsConfig.js'; -import type { - PerpsFeeResolution, - PerpsFeeSource, - PerpsPlatformDependencies, - PerpsSubscriptionBenefits, - PerpsSubscriptionFeeWaiverStatus, -} from '../types/index.js'; +import { PERPS_CONSTANTS } from '../constants/perpsConfig.js'; +import type { PerpsPlatformDependencies } from '../types/index.js'; import type { PerpsControllerMessengerBase } from '../types/messenger.js'; import { getSelectedEvmAccountFromMessenger } from '../utils/accountUtils.js'; import { ensureError } from '../utils/errorUtils.js'; import { formatAccountToCaipAccountId } from '../utils/rewardsUtils.js'; -/** - * Default MetaMask builder fee, in basis points. - * This is the fee every user pays when no cheaper source applies. - */ -const DEFAULT_FEE_BIPS = - BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR; - -/** - * Cached subscription benefits plus the time they were read. - */ -type BenefitsSnapshot = { - benefits: PerpsSubscriptionBenefits | null; - fetchedAt: number; -}; - /** * RewardsIntegrationService * - * Owns the unified perps fee resolver: it considers every fee source and - * returns the lowest fee, expressed as the discount bips providers consume. - * - * Sources, all in fee basis points (lowest wins): - * - `default` — {@link BUILDER_FEE_CONFIG}, the fee with no reductions. - * - `rewards` — VIP and season, collapsed into one discount by - * `RewardsController` (`rewards.getPerpsDiscountForAccount`), so this service - * does not re-derive the VIP/season split. - * - `subscription` — `0` bips, but only when the eligibility gate passes on a - * cached read of the profile's benefits. - * - * On a tie the cheaper-to-explain source wins, in the order - * `subscription` > `rewards` > `default`. - * - * The benefits cache is stale-while-revalidate: fee resolution is a pure read - * of the cached snapshot, while preview and lifecycle callers refresh it - * explicitly. Nothing is reserved or committed client-side, so backend - * exhaustion needs no release logic — the next refresh simply stops passing - * the gate. + * Handles rewards-related operations and fee discount calculations. + * Stateless service that coordinates with RewardsController and NetworkController. * * Instance-based service with constructor injection of platform dependencies. */ @@ -63,31 +22,6 @@ export class RewardsIntegrationService { readonly #messenger: PerpsControllerMessengerBase; - /** Last successful benefits read, or undefined before the first one. */ - #benefitsSnapshot: BenefitsSnapshot | undefined; - - /** - * When the last benefits read finished, successful or not. - * - * Separate from `#benefitsSnapshot.fetchedAt`, which only advances on - * success: a failing read must still throttle the next preview refresh, - * otherwise an outage turns every fee preview into a new request. - */ - #lastAttemptAt: number | undefined; - - /** In-flight refresh, deduped so only one runs at a time. */ - #benefitsRefresh: Promise | undefined; - - /** - * Identity generation for the cached benefits. - * - * Bumped by {@link invalidateSubscriptionBenefits}; a read that resolves - * against a superseded epoch is discarded rather than written back, so a - * refresh issued for the previous profile cannot repopulate the cache after - * a sign-out or profile switch. - */ - #benefitsEpoch = 0; - /** * Create a new RewardsIntegrationService instance * @@ -122,238 +56,12 @@ export class RewardsIntegrationService { } /** - * Calculate user fee discount from the unified fee resolver. + * Calculate user fee discount from rewards * Returns discount in basis points (e.g., 6500 = 65% discount) * - * @returns The fee discount in basis points, or undefined if no source resolved. + * @returns The fee discount in basis points, or undefined if unavailable. */ async calculateUserFeeDiscount(): Promise { - const resolution = await this.resolveFee(); - return resolution.discountBips; - } - - /** - * Resolve the MetaMask builder fee across every source and return the lowest. - * - * Never throws and never starts a subscription benefits read: a failing or - * unresolved cached source simply drops out of the comparison, so the worst - * case is the default fee rather than an error or an over-granted waiver. - * - * @returns The winning fee, its source, and the subscription gate outcome. - */ - async resolveFee(): Promise { - const rewardsDiscountBips = await this.#calculateRewardsDiscount(); - // Pure cache read: subscription benefits must never start a network request - // while an order is being prepared for signing. - const subscription = this.getSubscriptionFeeWaiverStatus(); - - let feeBips = DEFAULT_FEE_BIPS; - let source: PerpsFeeSource = 'default'; - - if (rewardsDiscountBips !== undefined) { - const rewardsFeeBips = - DEFAULT_FEE_BIPS * (1 - rewardsDiscountBips / BASIS_POINTS_DIVISOR); - // `<=` so an equal rewards fee still reports the rewards source, keeping - // a resolved 0% discount distinguishable from an unresolved one. - if (rewardsFeeBips <= feeBips) { - feeBips = rewardsFeeBips; - source = 'rewards'; - } - } - - // Nothing can undercut a waived fee, so the gate passing always wins. - if (subscription.eligible) { - feeBips = 0; - source = 'subscription'; - } - - const discountBips = - source === 'default' - ? undefined - : Math.round((1 - feeBips / DEFAULT_FEE_BIPS) * BASIS_POINTS_DIVISOR); - - this.#deps.debugLogger.log('RewardsIntegrationService: Fee resolved', { - source, - feeBips, - discountBips, - defaultFeeBips: DEFAULT_FEE_BIPS, - rewardsDiscountBips, - subscriptionEligible: subscription.eligible, - subscriptionReason: subscription.reason, - }); - - return { feeBips, discountBips, source, subscription }; - } - - /** - * Read the subscription fee-waiver gate from the cached benefits snapshot. - * - * Synchronous and side-effect free. The returned value always comes from - * what is already cached; preview and lifecycle callers own hydration. - * - * @returns Whether the waiver applies, why, and the remaining notional. - */ - getSubscriptionFeeWaiverStatus(): PerpsSubscriptionFeeWaiverStatus { - if (!this.#deps.subscription) { - return { eligible: false, reason: 'no-source' }; - } - - const now = Date.now(); - const snapshot = this.#benefitsSnapshot; - const age = snapshot ? now - snapshot.fetchedAt : Infinity; - if (!snapshot) { - return { eligible: false, reason: 'not-hydrated' }; - } - - if (age > SUBSCRIPTION_BENEFITS_CACHE.MaxStaleMs) { - // Past the ceiling we cannot tell whether the cap is still available, so - // fall back to the next-lowest source rather than over-granting. - return { eligible: false, reason: 'stale' }; - } - - return evaluateFeeWaiverGate(snapshot.benefits); - } - - /** - * Refresh the cached subscription benefits snapshot. - * - * Deduped: concurrent callers share the in-flight request. Rejections are - * logged and swallowed, leaving the previous snapshot in place. Preview and - * lifecycle callers invoke this outside order submission. - * - * @returns A promise that settles when the refresh completes. - */ - async refreshSubscriptionBenefits(): Promise { - const source = this.#deps.subscription; - if (!source) { - return; - } - - if (this.#benefitsRefresh) { - await this.#benefitsRefresh; - return; - } - - const now = Date.now(); - const snapshotAge = this.#benefitsSnapshot - ? now - this.#benefitsSnapshot.fetchedAt - : Infinity; - const sinceAttempt = - this.#lastAttemptAt === undefined ? Infinity : now - this.#lastAttemptAt; - if ( - snapshotAge < SUBSCRIPTION_BENEFITS_CACHE.FreshMs || - sinceAttempt < SUBSCRIPTION_BENEFITS_CACHE.FreshMs - ) { - return; - } - - const refresh = this.#readSubscriptionBenefits(source); - this.#benefitsRefresh = refresh; - // `finally` always defers, so this never clears the handle we just set. - refresh - .finally(() => { - if (this.#benefitsRefresh === refresh) { - this.#benefitsRefresh = undefined; - } - }) - .catch(() => undefined); - - await refresh; - } - - /** - * Drop the cached benefits snapshot. - * - * Call this when the identity behind the benefits changes — sign-out, or a - * profile switch — since the snapshot carries no profile identity of its own - * and would otherwise keep answering for the previous profile until the next - * successful refresh. The next status read reports `not-hydrated`, so the - * waiver is withheld until a preview or lifecycle caller hydrates it. - */ - invalidateSubscriptionBenefits(): void { - this.#benefitsSnapshot = undefined; - this.#lastAttemptAt = undefined; - // Fence any in-flight read: it was issued for the previous identity, so its - // result must not repopulate the cache after this point. - this.#benefitsEpoch += 1; - // Drop the dedupe handle too. The fenced read can only be discarded, so - // leaving it in place would make the next refresh await it instead of - // fetching for the new identity. Its `finally` guard compares against the - // current handle, so it will not clear whatever replaces it here. - this.#benefitsRefresh = undefined; - - this.#deps.debugLogger.log( - 'RewardsIntegrationService: Subscription benefits cache invalidated', - ); - } - - /** - * Perform one benefits read and store it, keeping the previous snapshot on - * error. Never rejects, so callers cannot produce an unhandled rejection. - * - * @param source - The injected subscription benefits source. - */ - async #readSubscriptionBenefits( - source: NonNullable, - ): Promise { - const epoch = this.#benefitsEpoch; - - try { - const benefits = await source.getPerpsBenefits(); - - if (epoch !== this.#benefitsEpoch) { - // Invalidated while this read was in flight: it belongs to a previous - // identity, so discarding it is the only safe outcome. - this.#deps.debugLogger.log( - 'RewardsIntegrationService: Discarding benefits read from a previous identity', - ); - return; - } - - this.#benefitsSnapshot = { benefits, fetchedAt: Date.now() }; - - this.#deps.debugLogger.log( - 'RewardsIntegrationService: Subscription benefits refreshed', - { - status: benefits?.status, - entitled: benefits?.perpsFeeWaiver?.entitled, - usage: benefits?.perpsFeeWaiver?.usage, - exhausted: benefits?.perpsFeeWaiver?.exhausted, - }, - ); - } catch (error) { - // Keep the previous snapshot: an unreachable benefits endpoint must not - // erase a valid cache, and it must never grant the waiver either. - this.#deps.logger.error( - ensureError( - error, - 'RewardsIntegrationService.refreshSubscriptionBenefits', - ), - { - tags: { feature: PERPS_CONSTANTS.FeatureName }, - context: { - name: 'RewardsIntegrationService.refreshSubscriptionBenefits', - data: {}, - }, - }, - ); - } finally { - // Recorded on failure too — this is what throttles the retry loop. Not - // recorded for a fenced read: that attempt belongs to a previous - // identity, and letting it throttle would delay the new identity's first - // fetch by a whole freshness window. - if (epoch === this.#benefitsEpoch) { - this.#lastAttemptAt = Date.now(); - } - } - } - - /** - * Resolve the rewards (VIP + season) discount for the selected account. - * - * @returns The discount in basis points, or undefined when unavailable. - */ - async #calculateRewardsDiscount(): Promise { try { const evmAccount = getSelectedEvmAccountFromMessenger(this.#messenger); @@ -415,7 +123,7 @@ export class RewardsIntegrationService { // bips to convert an absolute VIP fee into a discount fraction. const discountBips = await this.#deps.rewards.getPerpsDiscountForAccount( caipAccountId, - DEFAULT_FEE_BIPS, + BUILDER_FEE_CONFIG.MaxFeeDecimal * BASIS_POINTS_DIVISOR, ); // null = subscription state not hydrated yet; surface as undefined so @@ -457,47 +165,3 @@ export class RewardsIntegrationService { } } } - -/** - * Evaluate the perps fee-waiver eligibility gate against a benefits snapshot. - * - * The gate is `status=active` AND `perpsFeeWaiver` entitled AND - * `usage=available`. A backend `exhausted` flag (or an `exhausted` usage) fails - * the gate on its own; anything short of an affirmative `available` is treated - * as not entitled, because the waiver is only granted on positive evidence. - * A `null` payload means there is no subscription at all, which is reported - * separately from a subscription that exists but is not active. - * - * @param benefits - The cached benefits payload, or null when there is none. - * @returns The gate outcome plus the remaining notional when reported. - */ -function evaluateFeeWaiverGate( - benefits: PerpsSubscriptionBenefits | null, -): PerpsSubscriptionFeeWaiverStatus { - const waiver = benefits?.perpsFeeWaiver; - const { remainingNotionalUsd } = waiver ?? {}; - - // `null` is the DI contract's "nothing to report" (signed out, no profile), - // which is distinct from a subscription that exists but is not active. - if (benefits === null) { - return { eligible: false, reason: 'no-subscription' }; - } - - if (benefits.status !== 'active') { - return { eligible: false, reason: 'inactive', remainingNotionalUsd }; - } - - if (waiver?.entitled !== true) { - return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; - } - - if (waiver.exhausted === true || waiver.usage === 'exhausted') { - return { eligible: false, reason: 'exhausted', remainingNotionalUsd }; - } - - if (waiver.usage !== 'available') { - return { eligible: false, reason: 'not-entitled', remainingNotionalUsd }; - } - - return { eligible: true, reason: 'eligible', remainingNotionalUsd }; -} diff --git a/packages/perps-controller/src/services/ServiceContext.ts b/packages/perps-controller/src/services/ServiceContext.ts index 8a19411deaf..29d09e63cf5 100644 --- a/packages/perps-controller/src/services/ServiceContext.ts +++ b/packages/perps-controller/src/services/ServiceContext.ts @@ -1,10 +1,5 @@ import type { PerpsControllerState } from '../PerpsController.js'; -import type { - Order, - PerpsGlobalSnapshotRequest, - PerpsSubscriptionFeeWaiverStatus, - Position, -} from '../types/index.js'; +import type { Order, Position } from '../types/index.js'; /** * ServiceContext @@ -65,24 +60,6 @@ export type ServiceContext = { getOpenOrders?: () => Promise; getPositions?: () => Promise; - /** - * Exact per-call identity and guards for adopting a global market snapshot. - * Omitted when the active provider or DEX configuration is not static. - */ - globalSnapshot?: { - request: PerpsGlobalSnapshotRequest; - isCurrent: () => boolean; - isMarketAllowed: (symbol: string) => boolean; - }; - - /** - * Cached subscription fee-waiver status for read-only fee previews. - * Read by the controller from `RewardsIntegrationService` — the same cached - * benefits snapshot the fee resolver uses — and omitted entirely when no - * subscription source is wired. - */ - subscriptionFeeWaiver?: PerpsSubscriptionFeeWaiverStatus; - /** * Callback functions for controller-specific operations */ diff --git a/packages/perps-controller/src/services/TerminalMarketService.ts b/packages/perps-controller/src/services/TerminalMarketService.ts index 62c9ce85a55..1330b8f5e85 100644 --- a/packages/perps-controller/src/services/TerminalMarketService.ts +++ b/packages/perps-controller/src/services/TerminalMarketService.ts @@ -5,94 +5,29 @@ import { is, nullable, number, - object, optional, string, - tuple, type, union, } from '@metamask/superstruct'; -import { bytesToHex, sha256, stringToBytes } from '@metamask/utils'; -import { canonicalizeHyperLiquidDexes } from '../constants/hyperLiquidConfig.js'; import { PERPS_CONSTANTS, TERMINAL_API_CONFIG, } from '../constants/perpsConfig.js'; import type { MarketInfo, - PerpsGlobalSnapshotRequest, - PerpsGlobalSnapshotResult, - PerpsMarketData, PerpsPlatformDependencies, TerminalAssetMetadata, } from '../types/index.js'; import { MarketCategory } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; -import { formatChange } from '../utils/marketDataTransform.js'; -import { clonePerpsMarketData } from '../utils/marketUtils.js'; const VALID_MARKET_TYPES = new Set(Object.values(MarketCategory)); -const GLOBAL_SNAPSHOT_SCHEMA_VERSION = 2; -const GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS = 30_000; -const GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES = 1_048_576; -const GLOBAL_SNAPSHOT_PERCENT_TOLERANCE = 0.01; -const GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS = 5_000; -const MINIMUM_EPOCH_MILLISECONDS = Date.UTC(2000, 0, 1); -const DECIMAL_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/u; -const NON_NEGATIVE_DECIMAL_PATTERN = /^(?:0|[1-9]\d*)(?:\.\d+)?$/u; -const DEX_PATTERN = /^(?:main|[a-z0-9][a-z0-9-]*)$/u; - -const GlobalSnapshotMarketStruct = object({ - symbol: string(), - provider: string(), - dex: string(), - name: nullable(string()), - description: nullable(string()), - iconUrl: nullable(string()), - szDecimals: number(), - maxLeverage: number(), - markPrice: string(), - price: string(), - midPrice: nullable(string()), - oraclePrice: string(), - change24h: string(), - changePercent24h: number(), - funding: string(), - volume24h: string(), - openInterest: string(), - category: nullable(string()), - keywords: nullable(array(string())), - tags: nullable(array(string())), - listedAt: nullable(number()), - trend: array(tuple([number(), string()])), -}); - -const GlobalSnapshotStruct = object({ - schemaVersion: number(), - provider: string(), - network: string(), - enabledDexes: array(string()), - fingerprint: string(), - generatedAt: number(), - receivedAt: number(), - maxAgeMs: number(), - complete: boolean(), - perDexErrors: array( - object({ - dex: string(), - error: string(), - }), - ), - markets: array(GlobalSnapshotMarketStruct), -}); - -type GlobalSnapshotMarket = Infer; -type GlobalSnapshot = Infer; /** * Runtime validation schema for a single market item returned by - * `GET {terminalApi.marketDataUrl}`. + * `GET {terminalApiUrl}`. * * Uses `type()` (loose object matching) so that extra fields the API sends * (e.g. `price`, `iconUrl`, `trend`) are silently accepted. @@ -138,15 +73,6 @@ export class TerminalMarketService { #cache: CacheEntry | null = null; - readonly #globalSnapshotCache = new Map(); - - readonly #globalSnapshotInFlight = new Map< - string, - Promise - >(); - - #globalSnapshotGeneration = 0; - constructor(deps: PerpsPlatformDependencies) { this.#deps = deps; } @@ -171,13 +97,13 @@ export class TerminalMarketService { }; } - const marketDataUrl = - this.#deps.terminalApi?.marketDataUrl ?? this.#deps.terminalApiUrl; - if (!marketDataUrl) { - throw new Error('Terminal API market-data URL not configured'); + if (!this.#deps.terminalApiUrl) { + throw new Error( + 'Terminal API URL not configured (terminalApiUrl is required)', + ); } - const url = marketDataUrl; + const url = this.#deps.terminalApiUrl; const controller = new AbortController(); const timeoutId = setTimeout( () => controller.abort(new Error('Terminal API fetch timed out')), @@ -215,470 +141,11 @@ export class TerminalMarketService { return { markets, metadata }; } - /** - * Fetch, authenticate by exact identity, and map a schema-v2 atomic market - * snapshot. Accepted entries remain inside the source freshness window; - * rejected responses are never cached. - * - * @param request - Exact provider/network/DEX identity expected by the client. - * @returns UI-ready market data and its source-bounded expiry. - */ - async fetchGlobalSnapshot( - request: PerpsGlobalSnapshotRequest, - ): Promise { - const identity = this.#validateRequestedIdentity(request); - if (!this.#deps.terminalApi?.globalSnapshotUrl) { - throw new Error('Terminal global snapshot URL not configured'); - } - - const url = this.#buildGlobalSnapshotUrl( - this.#deps.terminalApi.globalSnapshotUrl, - identity, - ); - const cacheKey = [ - url, - String(GLOBAL_SNAPSHOT_SCHEMA_VERSION), - identity.provider, - identity.network, - identity.enabledDexes.join(','), - ].join('|'); - const now = Date.now(); - const cached = this.#globalSnapshotCache.get(cacheKey); - if (cached && now < cached.expiresAt) { - return this.#cloneGlobalSnapshotResult(cached); - } - if (cached) { - this.#globalSnapshotCache.delete(cacheKey); - } - - const existing = this.#globalSnapshotInFlight.get(cacheKey); - if (existing) { - return this.#cloneGlobalSnapshotResult(await existing); - } - - const generation = this.#globalSnapshotGeneration; - const pending = this.#fetchAndValidateGlobalSnapshot(identity, url).then( - (result) => { - if (this.#globalSnapshotGeneration !== generation) { - return result; - } - this.#globalSnapshotCache.set(cacheKey, result); - return result; - }, - ); - this.#globalSnapshotInFlight.set(cacheKey, pending); - try { - return this.#cloneGlobalSnapshotResult(await pending); - } finally { - if (this.#globalSnapshotInFlight.get(cacheKey) === pending) { - this.#globalSnapshotInFlight.delete(cacheKey); - } - } - } - - async #fetchAndValidateGlobalSnapshot( - identity: PerpsGlobalSnapshotRequest, - url: string, - ): Promise { - const controller = new AbortController(); - const timeoutId = setTimeout( - () => controller.abort(new Error('Terminal global snapshot timed out')), - TERMINAL_API_CONFIG.FetchTimeoutMs, - ); - - try { - const response = await fetch(url, { - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - signal: controller.signal, - }); - - if (!response.ok) { - throw new Error( - `Terminal global snapshot returned ${String(response.status)}: ${response.statusText}`, - ); - } - - const declaredLength = response.headers?.get('content-length'); - if ( - declaredLength !== null && - declaredLength !== undefined && - /^\d+$/u.test(declaredLength) && - Number(declaredLength) > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES - ) { - throw new Error('Terminal global snapshot payload exceeds 1 MiB'); - } - // React Native fetch does not consistently expose a streaming reader. - // Reject declared oversize bodies before allocation, then enforce the same - // byte cap after text() for servers that omit Content-Length. - const text = await response.text(); - if (stringToBytes(text).byteLength > GLOBAL_SNAPSHOT_MAX_PAYLOAD_BYTES) { - throw new Error('Terminal global snapshot payload exceeds 1 MiB'); - } - let body: unknown; - try { - body = JSON.parse(text) as unknown; - } catch { - throw new Error('Terminal global snapshot returned invalid JSON'); - } - if (!is(body, GlobalSnapshotStruct)) { - throw new Error('Terminal global snapshot failed schema validation'); - } - return this.#validateAndMapGlobalSnapshot(body, identity, Date.now()); - } finally { - clearTimeout(timeoutId); - } - } - - async #validateAndMapGlobalSnapshot( - snapshot: GlobalSnapshot, - identity: PerpsGlobalSnapshotRequest, - now: number, - ): Promise { - if (snapshot.schemaVersion !== GLOBAL_SNAPSHOT_SCHEMA_VERSION) { - throw new Error('Terminal global snapshot schema version mismatch'); - } - if ( - snapshot.provider !== identity.provider || - snapshot.network !== identity.network - ) { - throw new Error('Terminal global snapshot identity mismatch'); - } - - const responseDexes = this.#normalizeDexes(snapshot.enabledDexes); - if ( - responseDexes.length !== identity.enabledDexes.length || - responseDexes.some((dex, index) => dex !== identity.enabledDexes[index]) - ) { - throw new Error('Terminal global snapshot DEX mismatch'); - } - const expectedFingerprint = await this.#createFingerprint(identity); - if (snapshot.fingerprint !== expectedFingerprint) { - throw new Error('Terminal global snapshot fingerprint mismatch'); - } - if (!snapshot.complete || snapshot.perDexErrors.length > 0) { - throw new Error('Terminal global snapshot is incomplete'); - } - if ( - !this.#isNonNegativeSafeInteger(snapshot.generatedAt) || - !this.#isNonNegativeSafeInteger(snapshot.receivedAt) || - !this.#isPositiveSafeInteger(snapshot.maxAgeMs) || - snapshot.receivedAt > snapshot.generatedAt || - snapshot.generatedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS || - snapshot.receivedAt > now + GLOBAL_SNAPSHOT_MAX_FUTURE_CLOCK_SKEW_MS - ) { - throw new Error('Terminal global snapshot has invalid timestamps'); - } - - const trustedMaxAgeMs = Math.min( - snapshot.maxAgeMs, - GLOBAL_SNAPSHOT_CONSUMER_MAX_AGE_MS, - ); - const expiresAt = snapshot.receivedAt + trustedMaxAgeMs; - if (now >= expiresAt) { - throw new Error('Terminal global snapshot is stale'); - } - if (snapshot.markets.length === 0) { - throw new Error('Terminal global snapshot has no markets'); - } - - const marketKeys = new Set(); - const representedDexes = new Set(); - const markets = snapshot.markets - .map((market, index) => { - this.#validateSnapshotMarket( - market, - identity, - index, - snapshot.generatedAt, - ); - const key = `${market.dex}:${market.symbol}`; - if (marketKeys.has(key)) { - throw new Error(`Terminal global snapshot duplicates market ${key}`); - } - marketKeys.add(key); - representedDexes.add(market.dex); - return market; - }) - .map((market) => this.#mapSnapshotMarket(market, expiresAt)); - if (identity.enabledDexes.some((dex) => !representedDexes.has(dex))) { - throw new Error('Terminal global snapshot is missing a requested DEX'); - } - if (markets.length === 0) { - throw new Error('Terminal global snapshot has no tradable markets'); - } - - return { markets, expiresAt }; - } - - #validateRequestedIdentity( - request: PerpsGlobalSnapshotRequest, - ): PerpsGlobalSnapshotRequest { - if (request.provider !== 'hyperliquid') { - throw new Error('Terminal global snapshot provider is unsupported'); - } - if (request.network !== 'mainnet' && request.network !== 'testnet') { - throw new Error('Terminal global snapshot network is unsupported'); - } - return { - provider: request.provider, - network: request.network, - enabledDexes: this.#normalizeDexes(request.enabledDexes), - }; - } - - #buildGlobalSnapshotUrl( - baseUrl: string, - identity: PerpsGlobalSnapshotRequest, - ): string { - const query = new URLSearchParams({ - provider: identity.provider, - network: identity.network, - dexes: identity.enabledDexes.join(','), - }); - return `${baseUrl}${baseUrl.includes('?') ? '&' : '?'}${query.toString()}`; - } - - #normalizeDexes(dexes: string[]): string[] { - if (!Array.isArray(dexes) || dexes.length === 0) { - throw new Error('Terminal global snapshot requires at least one DEX'); - } - const normalized = dexes.map((dex) => { - if (typeof dex !== 'string' || !DEX_PATTERN.test(dex)) { - throw new Error('Terminal global snapshot contains an invalid DEX'); - } - return dex; - }); - if (new Set(normalized).size !== normalized.length) { - throw new Error('Terminal global snapshot contains duplicate DEXes'); - } - if (!normalized.includes('main')) { - throw new Error('Terminal global snapshot requires the main DEX'); - } - return canonicalizeHyperLiquidDexes(normalized); - } - - async #createFingerprint( - identity: PerpsGlobalSnapshotRequest, - ): Promise { - const canonicalIdentity = JSON.stringify({ - provider: identity.provider, - network: identity.network, - enabledDexes: identity.enabledDexes, - }); - const digest = await sha256(stringToBytes(canonicalIdentity)); - return `sha256:${bytesToHex(digest).slice(2)}`; - } - - #validateSnapshotMarket( - market: GlobalSnapshotMarket, - identity: PerpsGlobalSnapshotRequest, - index: number, - generatedAt: number, - ): void { - const invalid = (field: string): Error => - new Error( - `Terminal global snapshot market ${String(index)} has invalid ${field}`, - ); - if (!identity.enabledDexes.includes(market.dex)) { - throw invalid('dex'); - } - const expectedProvider = market.dex === 'main' ? 'hyperliquid' : market.dex; - if (market.provider !== expectedProvider) { - throw invalid('provider'); - } - const expectedPrefix = market.dex === 'main' ? '' : `${market.dex}:`; - if ( - market.symbol.length === 0 || - (expectedPrefix - ? !market.symbol.startsWith(expectedPrefix) - : market.symbol.includes(':')) - ) { - throw invalid('symbol'); - } - if ( - !this.#isNonNegativeSafeInteger(market.szDecimals) || - !this.#isPositiveSafeInteger(market.maxLeverage) || - (market.listedAt !== null && - (!this.#isNonNegativeSafeInteger(market.listedAt) || - market.listedAt < MINIMUM_EPOCH_MILLISECONDS || - market.listedAt > generatedAt)) - ) { - throw invalid('integer field'); - } - - const decimalFields: [string, string, boolean][] = [ - ['markPrice', market.markPrice, true], - ['price', market.price, true], - ['oraclePrice', market.oraclePrice, true], - ['change24h', market.change24h, false], - ['volume24h', market.volume24h, true], - ['openInterest', market.openInterest, true], - ['funding', market.funding, false], - ]; - if (market.midPrice !== null) { - decimalFields.push(['midPrice', market.midPrice, true]); - } - for (const [field, value, nonNegative] of decimalFields) { - const pattern = nonNegative - ? NON_NEGATIVE_DECIMAL_PATTERN - : DECIMAL_PATTERN; - if (!pattern.test(value) || !Number.isFinite(Number(value))) { - throw invalid(field); - } - } - if (market.price !== market.markPrice) { - throw invalid('deprecated price alias'); - } - if ( - Number(market.oraclePrice) <= 0 || - (market.midPrice !== null && Number(market.midPrice) <= 0) - ) { - throw invalid('reference price'); - } - const price = Number(market.markPrice); - const change24h = Number(market.change24h); - const previousPrice = price - change24h; - if (price <= 0 || previousPrice <= 0 || !Number.isFinite(previousPrice)) { - throw invalid('mark/change coherence'); - } - const derivedPercent = (change24h / previousPrice) * 100; - if ( - !Number.isFinite(derivedPercent) || - !Number.isFinite(market.changePercent24h) || - Math.abs(market.changePercent24h - derivedPercent) > - GLOBAL_SNAPSHOT_PERCENT_TOLERANCE - ) { - throw invalid('changePercent24h coherence'); - } - for (const [field, values] of [ - ['keywords', market.keywords], - ['tags', market.tags], - ] as const) { - if ( - values !== null && - (values.some((value) => value.length === 0) || - new Set(values).size !== values.length) - ) { - throw invalid(field); - } - } - for (const [field, value] of [ - ['name', market.name], - ['description', market.description], - ['iconUrl', market.iconUrl], - ['category', market.category], - ] as const) { - if (value !== null && value.length === 0) { - throw invalid(field); - } - } - let previousTrendTimestamp = -1; - for (const [timestamp, trendPrice] of market.trend) { - if ( - !this.#isNonNegativeSafeInteger(timestamp) || - timestamp < MINIMUM_EPOCH_MILLISECONDS || - timestamp > generatedAt || - timestamp <= previousTrendTimestamp || - !NON_NEGATIVE_DECIMAL_PATTERN.test(trendPrice) || - !Number.isFinite(Number(trendPrice)) || - Number(trendPrice) <= 0 - ) { - throw invalid('trend'); - } - previousTrendTimestamp = timestamp; - } - } - - #mapSnapshotMarket( - market: GlobalSnapshotMarket, - sourceExpiresAt: number, - ): PerpsMarketData { - const formatters = this.#deps.marketDataFormatters; - // Keep both provider price semantics explicit in the wire contract. Core - // maps markPrice to its UI price while retaining validation of midPrice. - const price = Number(market.markPrice); - const change24h = Number(market.change24h); - const volume = Number(market.volume24h); - const openInterest = Number(market.openInterest); - const isHip3 = market.dex !== 'main'; - const marketType = this.#marketTypeFor(market.dex, market.category); - - return { - symbol: market.symbol, - name: market.name ?? market.symbol, - ...(market.description !== null && { - description: market.description, - }), - maxLeverage: `${String(market.maxLeverage)}x`, - price: formatters.formatPerpsFiat(price, { - ranges: formatters.priceRangesUniversal, - }), - change24h: formatChange(change24h, formatters), - change24hPercent: formatters.formatPercentage(market.changePercent24h), - volume: formatters.formatVolume(volume), - openInterest: formatters.formatVolume(openInterest), - fundingRate: Number(market.funding), - marketSource: isHip3 ? market.dex : undefined, - marketType, - isHip3, - isNewMarket: isHip3 && marketType === undefined, - ...(market.keywords && { keywords: market.keywords }), - ...(market.tags && { tags: market.tags }), - ...(market.category && { categories: [market.category] }), - ...(market.listedAt !== null && { listedAt: market.listedAt }), - trend: market.trend, - dataSource: 'terminal-global-snapshot-mark', - sourceExpiresAt, - }; - } - - #marketTypeFor( - dex: string, - category: string | null, - ): TerminalAssetMetadata['marketType'] | undefined { - if (dex === 'main') { - return MarketCategory.CryptoCurrency; - } - if (category === 'stocks') { - return MarketCategory.Stock; - } - if (category === 'pre_ipo') { - return MarketCategory.PreIpo; - } - if (category && VALID_MARKET_TYPES.has(category)) { - return category as TerminalAssetMetadata['marketType']; - } - return undefined; - } - - #cloneGlobalSnapshotResult( - result: PerpsGlobalSnapshotResult, - ): PerpsGlobalSnapshotResult { - return { - expiresAt: result.expiresAt, - markets: clonePerpsMarketData(result.markets), - }; - } - - #isNonNegativeSafeInteger(value: unknown): value is number { - return ( - typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 - ); - } - - #isPositiveSafeInteger(value: unknown): value is number { - return this.#isNonNegativeSafeInteger(value) && value > 0; - } - /** * Invalidate the internal cache so the next fetch hits the network. */ clearCache(): void { this.#cache = null; - this.#globalSnapshotGeneration += 1; - this.#globalSnapshotCache.clear(); - this.#globalSnapshotInFlight.clear(); } /** @@ -819,12 +286,7 @@ export class TerminalMarketService { tags: { feature: PERPS_CONSTANTS.FeatureName, source: 'terminal-api' }, context: { name: `TerminalMarketService.${method}`, - data: { - url: method.includes('globalSnapshot') - ? this.#deps.terminalApi?.globalSnapshotUrl - : (this.#deps.terminalApi?.marketDataUrl ?? - this.#deps.terminalApiUrl), - }, + data: { url: this.#deps.terminalApiUrl }, }, }, ); diff --git a/packages/perps-controller/src/services/TradingService.ts b/packages/perps-controller/src/services/TradingService.ts index f0bee6b9087..b2cb7a2cef0 100644 --- a/packages/perps-controller/src/services/TradingService.ts +++ b/packages/perps-controller/src/services/TradingService.ts @@ -30,7 +30,6 @@ import type { UpdatePositionTPSLParams, PerpsAnalyticsProperties, PerpsPlatformDependencies, - PerpsFeeResolution, } from '../types/index.js'; import { ensureError } from '../utils/errorUtils.js'; import { isLimitExecutionOrderType } from '../utils/orderTypes.js'; @@ -77,9 +76,6 @@ export class TradingService { */ #controllerDeps: TradingServiceControllerDeps | null = null; - /** Serializes provider fee context so concurrent orders cannot share it. */ - #feeContextTail: Promise = Promise.resolve(); - /** * Create a new TradingService instance * @@ -433,35 +429,25 @@ export class TradingService { * * @param options - The configuration options. * @param options.provider - The perps provider instance. - * @param options.feeResolution - The resolved fee and attribution source. + * @param options.feeDiscountBips - The fee discount bips value. * @param options.operation - The operation value. * @returns The result of the operation. */ async #withFeeDiscount(options: { provider: PerpsProvider; - feeResolution?: PerpsFeeResolution; + feeDiscountBips?: number; operation: () => Promise; }): Promise { - const { provider, feeResolution, operation } = options; - const previous = this.#feeContextTail; - let release: () => void = () => undefined; - this.#feeContextTail = new Promise((resolve) => { - release = resolve; - }); - await previous; + const { provider, feeDiscountBips, operation } = options; try { - if (provider.setUserFeeResolution) { - provider.setUserFeeResolution(feeResolution); - } else if (provider.setUserFeeDiscount) { - provider.setUserFeeDiscount(feeResolution?.discountBips); - } - if (feeResolution) { + // Set discount context in provider for this operation + if (feeDiscountBips !== undefined && provider.setUserFeeDiscount) { + provider.setUserFeeDiscount(feeDiscountBips); this.#deps.debugLogger.log( - 'TradingService: Fee resolution set in provider', + 'TradingService: Fee discount set in provider', { - feeDiscountBips: feeResolution.discountBips, - feeSource: feeResolution.source, + feeDiscountBips, }, ); } @@ -470,15 +456,12 @@ export class TradingService { return await operation(); } finally { // Always clear discount context, even on exception - if (provider.setUserFeeResolution) { - provider.setUserFeeResolution(undefined); - } else if (provider.setUserFeeDiscount) { + if (provider.setUserFeeDiscount) { provider.setUserFeeDiscount(undefined); + this.#deps.debugLogger.log( + 'TradingService: Fee discount cleared from provider', + ); } - this.#deps.debugLogger.log( - 'TradingService: Fee resolution cleared from provider', - ); - release(); } } @@ -558,12 +541,11 @@ export class TradingService { }); // Calculate fee discount at execution time (fresh, secure) - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); - this.#deps.debugLogger.log('TradingService: Fee resolution calculated', { - feeDiscountBips: feeResolution?.discountBips, - feeSource: feeResolution?.source, - hasDiscount: feeResolution?.discountBips !== undefined, + this.#deps.debugLogger.log('TradingService: Fee discount calculated', { + feeDiscountBips, + hasDiscount: feeDiscountBips !== undefined, }); this.#deps.debugLogger.log( @@ -624,7 +606,7 @@ export class TradingService { }, PERPS_CONSTANTS.PlaceOrderTimeoutMs); const result = await this.#withFeeDiscount({ provider, - feeResolution, + feeDiscountBips, operation: () => provider.placeOrder(params), }); if (orderSubmissionThresholdTimeoutId !== undefined) { @@ -1128,9 +1110,7 @@ export class TradingService { * * @returns The result of the operation. */ - async #calculateFeeDiscountWithMeasurement(): Promise< - PerpsFeeResolution | undefined - > { + async #calculateFeeDiscountWithMeasurement(): Promise { // Check if controller dependencies are available if (!this.#controllerDeps) { this.#deps.debugLogger.log( @@ -1144,7 +1124,8 @@ export class TradingService { const orderExecutionFeeDiscountStartTime = this.#deps.performance.now(); // Calculate fee discount using messenger pattern (service handles controller access internally) - const resolution = await rewardsIntegrationService.resolveFee(); + const discountBips = + await rewardsIntegrationService.calculateUserFeeDiscount(); const orderExecutionFeeDiscountDuration = this.#deps.performance.now() - orderExecutionFeeDiscountStartTime; @@ -1159,13 +1140,12 @@ export class TradingService { this.#deps.debugLogger.log( 'TradingService: Fee discount API call completed', { - discountBips: resolution.discountBips, - source: resolution.source, + discountBips, duration: `${orderExecutionFeeDiscountDuration.toFixed(0)}ms`, }, ); - return resolution; + return discountBips; } /** @@ -1209,12 +1189,12 @@ export class TradingService { }); // Calculate fee discount only if required dependencies are available - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); // Execute order edit with fee discount management const result = await this.#withFeeDiscount({ provider, - feeResolution, + feeDiscountBips, operation: () => provider.editOrder(params), }); @@ -1726,12 +1706,12 @@ export class TradingService { }); // Calculate fee discount with measurement - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); // Execute position close with fee discount management result = await this.#withFeeDiscount({ provider, - feeResolution, + feeDiscountBips, operation: () => provider.closePosition(params), }); @@ -1877,11 +1857,12 @@ export class TradingService { // Use batch close if provider supports it (provider handles filtering) if (provider.closePositions) { - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeDiscountBips = + await this.#calculateFeeDiscountWithMeasurement(); operationResult = await this.#withFeeDiscount({ provider, - feeResolution, + feeDiscountBips, operation: async () => { if (!provider.closePositions) { throw new Error('closePositions method not available'); @@ -2086,12 +2067,12 @@ export class TradingService { }); // Get fee discount from rewards - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); + const feeDiscountBips = await this.#calculateFeeDiscountWithMeasurement(); // Execute with fee discount management result = await this.#withFeeDiscount({ provider, - feeResolution, + feeDiscountBips, operation: () => provider.updatePositionTPSL(params), }); @@ -2391,13 +2372,8 @@ export class TradingService { ...this.#buildAttributionProperties(trackingData), }); - const feeResolution = await this.#calculateFeeDiscountWithMeasurement(); // Place flip order (HyperLiquid handles margin transfer automatically) - const result = await this.#withFeeDiscount({ - provider, - feeResolution, - operation: () => provider.placeOrder(orderParams), - }); + const result = await provider.placeOrder(orderParams); const completionDuration = this.#deps.performance.now() - startTime; diff --git a/packages/perps-controller/src/types/index.ts b/packages/perps-controller/src/types/index.ts index 05f64097681..7c132da0d21 100644 --- a/packages/perps-controller/src/types/index.ts +++ b/packages/perps-controller/src/types/index.ts @@ -260,15 +260,6 @@ export type OrderParams = { scaleMinPrice?: string; // Lowest limit price in the scale ladder scaleMaxPrice?: string; // Highest limit price in the scale ladder; must exceed scaleMinPrice scaleNumOrders?: number; // How many limit orders to spread across the ladder (2..20) - /** - * How the ladder's size is weighted across its rungs. Rung weights ramp - * linearly from 1 at `scaleMinPrice` to this value at `scaleMaxPrice`, in that - * direction for both sides — a short does not flip it. Above 1 puts more size - * at `scaleMaxPrice`, below 1 at `scaleMinPrice`. Omitted or exactly 1 spreads - * the size evenly. Any finite value above 0 is accepted as given; see - * `splitScaleSizes` for how the sizes are allocated. - */ - scaleSkew?: number; chaseIntervalMs?: number; // How often the chase re-reads the touch (default 3000, min 1000) chaseMaxDurationMs?: number; // Hard stop for the chase window (default 60000) chaseMaxRepricings?: number; // Cap on cancel/replace cycles (default 20) @@ -364,17 +355,11 @@ export type Position = { /** * Take profit price (if set). * - * Summary field, resolved for the common case a client renders: when - * `takeProfitOrders` holds exactly one order this is that order's trigger - * price, whether or not it covers the whole position. With two or more orders - * no single price describes them, so this falls back to the position-bound - * trigger — clients render `takeProfitCount` there instead. - * - * It may also reflect a TP/SL child of a *pending* order on this market, which - * `takeProfitOrders` and `takeProfitCount` deliberately exclude because such a - * child protects that order rather than the position. A position can therefore - * report a price here with an empty array and a count of `0`. Prefer - * `takeProfitOrders` for anything that must be exact. + * Legacy summary field: it may also reflect a TP/SL child of a *pending* order + * on this market, which `takeProfitOrders` and `takeProfitCount` deliberately + * exclude because such a child protects that order rather than the position. + * A position can therefore report a price here with an empty array and a count + * of `0`. Prefer `takeProfitOrders` for anything that must be exact. */ takeProfitPrice?: string; /** @@ -666,10 +651,6 @@ export type PerpsMarketData = { * Indicates this market snapshot came from the last known good cache after live fetch failure. */ isStale?: boolean; - /** Identifies an atomic Terminal summary whose price/change use mark semantics. */ - dataSource?: 'terminal-global-snapshot-mark'; - /** Source-bounded expiry for an atomic Terminal summary. */ - sourceExpiresAt?: number; /** * Searchable keywords from Terminal API metadata (e.g., ['defi', 'layer-1']) */ @@ -682,8 +663,6 @@ export type PerpsMarketData = { * Market categories from Terminal API metadata (e.g., ['crypto', 'meme']) */ categories?: string[]; - /** Timestamped hourly price points supplied by the atomic Terminal snapshot. */ - trend?: [timestampMs: number, price: string][]; /** * Epoch ms when this market was listed on the Terminal backend. * Sourced from the Terminal API `listedAt` field. @@ -918,10 +897,6 @@ export type HyperLiquidCredentials = { builderAddressTestnet?: string; /** Builder fee wallet address for mainnet. Empty/omitted = uses BUILDER_FEE_CONFIG default. */ builderAddressMainnet?: string; - /** Dedicated subscription waiver builder for testnet. */ - subscriptionBuilderAddressTestnet?: string; - /** Dedicated subscription waiver builder for mainnet. */ - subscriptionBuilderAddressMainnet?: string; }; export type MYXCredentials = { @@ -1016,23 +991,6 @@ export type GetAccountStateParams = { userAddress?: string; // Optional: required when standalone is true - user address to query account state for }; -export type GetUserDataSnapshotParams = { - userAddress: string; - identity: { - provider: 'hyperliquid'; - network: 'mainnet' | 'testnet'; - hip3ConfigVersion: number; - dexes: string[]; - }; -}; - -export type PerpsUserDataSnapshot = { - positions: Position[]; - orders: Order[]; - accountState: AccountState; - identity: GetUserDataSnapshotParams['identity'] & { address: string }; -}; - export type GetOrderFillsParams = { accountId?: CaipAccountId; // Optional: defaults to selected account user?: Hex; // Optional: user address (defaults to selected account) @@ -1105,7 +1063,7 @@ export type GetMarketsParams = { dex?: string; // HyperLiquid HIP-3: DEX name (empty string '' or undefined for main DEX). Other protocols: ignored. skipFilters?: boolean; // Skip market filtering (both allowlist and blocklist, default: false). When true, returns all markets without filtering. standalone?: boolean; // Lightweight mode: skip full initialization, only fetch market metadata (no wallet/WebSocket needed). Only main DEX markets returned. Use for discovery use cases like checking if a perps market exists. - useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. + useTerminalApi?: boolean; // When true, use Terminal API as market data source. }; /** @@ -1120,7 +1078,7 @@ export type GetMarketDataWithPricesParams = { sortBy?: SortField; // Sort results by this field direction?: SortDirection; // Sort direction (default: desc) limit?: number; // Maximum number of results to return - useTerminalApi?: boolean; // When true, enrich provider data from the legacy Terminal market endpoint. + useTerminalApi?: boolean; // When true, use Terminal API as market data source. }; export type SubscribePricesParams = { @@ -1268,115 +1226,6 @@ export type FeeCalculationResult = { volumeDiscount?: number; stakingDiscount?: number; }; - - /** - * Read-only subscription fee-waiver preview, sourced from the same cached - * benefits snapshot the fee resolver uses. Present only when the controller - * has a subscription source wired; the quoted rates above are not adjusted - * from it, so surfacing this never mutates the cap or the cache. - */ - subscription?: PerpsSubscriptionFeeWaiverStatus; -}; - -/** - * Usage state of the perps fee waiver on a subscription benefits snapshot. - */ -export type PerpsSubscriptionUsage = 'available' | 'exhausted'; - -/** - * Subscription benefits as returned by `GET /v1/profiles/{profileId}/benefits`. - * - * The perps controller never performs this request itself — the client owns the - * Profile JWT and injects the read through - * {@link PerpsPlatformDependencies.subscription}. Only the fields the perps fee - * waiver depends on are modelled here. - */ -export type PerpsSubscriptionBenefits = { - /** Subscription status; only `active` can pass the eligibility gate. */ - status: string; - - /** Perps fee waiver entitlement and its remaining allowance. */ - perpsFeeWaiver?: { - /** Whether the plan entitles this profile to the perps fee waiver. */ - entitled: boolean; - - /** Backend usage state; only `available` can pass the eligibility gate. */ - usage?: PerpsSubscriptionUsage; - - /** - * Set by the backend once the notional cap is crossed. Honored on the next - * cache refresh — there is no client-held reservation to release. - */ - exhausted?: boolean; - - /** Notional (USD) still covered by the waiver, for fee previews. */ - remainingNotionalUsd?: number; - }; -}; - -/** - * Why the subscription fee waiver did or did not apply, plus the remaining - * allowance for fee previews. Derived purely from the cached benefits snapshot. - */ -export type PerpsSubscriptionFeeWaiverStatus = { - /** True only when every condition of the eligibility gate passed. */ - eligible: boolean; - - /** - * Gate outcome: - * - `eligible` — every condition passed - * - `no-source` — no subscription dependency is wired - * - `not-hydrated` — nothing cached yet; a refresh was kicked off - * - `stale` — the cached snapshot is past the hard-stale ceiling - * - `no-subscription` — the read succeeded but reported no subscription at - * all (signed out, or no profile) - * - `inactive` — subscription status is not `active` - * - `not-entitled` — the plan does not include the perps fee waiver - * - `exhausted` — the backend reported the notional cap as spent - */ - reason: - | 'eligible' - | 'no-source' - | 'not-hydrated' - | 'stale' - | 'no-subscription' - | 'inactive' - | 'not-entitled' - | 'exhausted'; - - /** Notional (USD) still covered by the waiver, when the backend reports it. */ - remainingNotionalUsd?: number; -}; - -/** - * Fee source that won the unified resolver. - * - * `rewards` covers both VIP and season discounts: `RewardsController` already - * returns the better of the two as a single discount, so the perps controller - * treats them as one source rather than re-deriving the split. - */ -export type PerpsFeeSource = 'default' | 'rewards' | 'subscription'; - -/** - * Outcome of the unified fee resolver. - */ -export type PerpsFeeResolution = { - /** Winning MetaMask builder fee, in basis points (lowest across sources). */ - feeBips: number; - - /** - * Winning fee expressed as a discount off the default builder fee, in basis - * points — the unit providers consume. `undefined` when no source resolved - * (e.g. rewards state has not hydrated and no subscription waiver applies), - * so callers do not treat it as a definitive "no discount" answer. - */ - discountBips: number | undefined; - - /** Source that produced the winning fee. */ - source: PerpsFeeSource; - - /** Subscription gate outcome, always populated for observability. */ - subscription: PerpsSubscriptionFeeWaiverStatus; }; export type UpdatePositionTPSLParams = { @@ -1458,9 +1307,6 @@ export type PerpsProvider = { updateMargin(params: UpdateMarginParams): Promise; getPositions(params?: GetPositionsParams): Promise; getAccountState(params?: GetAccountStateParams): Promise; - getUserDataSnapshot?( - params: GetUserDataSnapshotParams, - ): Promise; getMarkets(params?: GetMarketsParams): Promise; getMarketDataWithPrices(): Promise; withdraw(params: WithdrawParams): Promise; // API operation - stays in provider @@ -1603,10 +1449,6 @@ export type PerpsProvider = { // Fee discount context (optional - for MetaMask reward discounts) setUserFeeDiscount?(discountBips: number | undefined): void; - // Full fee resolution context, including attribution source. - setUserFeeResolution?(resolution: PerpsFeeResolution | undefined): void; - /** Approve the dedicated subscription builder outside order submission. */ - approveSubscriptionBuilderFee?(): Promise; // HIP-3 (Builder-deployed DEXs) operations - optional for backward compatibility /** @@ -1952,20 +1794,8 @@ export type PerpsStreamManager = { */ export type PerpsPerformance = { now(): number; - /** - * Optional platform hook invoked once after constructor disk hydration. - * Receives `performance.now()` — not a Sentry write. - */ - onControllerConstructed?: (monotonicMs: number) => void; }; -type PerpsSetMeasurement = (( - name: string, - value: number, - unit: string, -) => void) & - ((name: string, value: number, unit: string, id: string) => void); - /** * Injectable tracer interface for Sentry/observability tracing. * Services use this to create spans and measure operation durations. @@ -1988,7 +1818,7 @@ export type PerpsTracer = { data?: Record; }): void; - setMeasurement: PerpsSetMeasurement; + setMeasurement(name: string, value: number, unit: string): void; addBreadcrumb(breadcrumb: { category: string; @@ -2066,22 +1896,6 @@ export type PerpsTerminalMarketService = { }>; clearCache(): void; logError(error: unknown, method: string): void; - fetchGlobalSnapshot?( - request: PerpsGlobalSnapshotRequest, - ): Promise; -}; - -/** Exact identity a client expects from an atomic global Perps snapshot. */ -export type PerpsGlobalSnapshotRequest = { - provider: 'hyperliquid'; - network: 'mainnet' | 'testnet'; - enabledDexes: string[]; -}; - -/** Validated snapshot data and its source-bounded expiry. */ -export type PerpsGlobalSnapshotResult = { - markets: PerpsMarketData[]; - expiresAt: number; }; /** @@ -2135,15 +1949,13 @@ export type PerpsPlatformDependencies = { }; // === Terminal API (market metadata source) === - terminalApi?: { - /** Full endpoint URL for the legacy perpetuals market-data endpoint. */ - marketDataUrl?: string; - - /** Full endpoint URL for the schema-v2 atomic global Perps snapshot. */ - globalSnapshotUrl?: string; - }; - - /** @deprecated Use `terminalApi.marketDataUrl`. */ + /** + * Full endpoint URL for the MetaMask Terminal API perpetuals endpoint. + * Each client build (dev/uat/prd) injects the correct environment URL + * (e.g. `https://terminal.api.cx.metamask.io/v1/perpetuals`). + * Never hardcoded in controller code — always provided by the platform. + * Optional: only required when Terminal API features (useTerminalApi) are enabled. + */ terminalApiUrl?: string; /** @@ -2172,28 +1984,6 @@ export type PerpsPlatformDependencies = { baseFeeBips: number, ): Promise; }; - - // === Subscription (DI — benefits endpoint is owned by the Subscription team) === - /** - * Optional subscription source for the unified fee resolver. - * - * The client owns the Profile JWT, so it performs - * `GET /v1/profiles/{profileId}/benefits` and hands the perps controller the - * parsed body. The controller caches the result stale-while-revalidate and - * never awaits this call on the order-signing path. - * - * Omit it entirely on clients that do not ship the subscription waiver; the - * resolver then falls back to the rewards and default sources. - */ - subscription?: { - /** - * Read the current profile's subscription benefits. - * Resolve `null` when there is no subscription to report (signed out, no - * profile). Rejections are tolerated: the resolver keeps the previous - * snapshot and never grants the waiver from a failed read. - */ - getPerpsBenefits(): Promise; - }; }; /** diff --git a/packages/perps-controller/src/types/perps-types.ts b/packages/perps-controller/src/types/perps-types.ts index 45e4fdac5de..7979d0f1edd 100644 --- a/packages/perps-controller/src/types/perps-types.ts +++ b/packages/perps-controller/src/types/perps-types.ts @@ -46,9 +46,8 @@ export type TriggerOrderType = * * - `twap`: slice the size over `OrderParams.twapDuration` minutes. Placed and * cancelled through the venue's own TWAP endpoints, not the order book. - * - `scale`: fan out `OrderParams.scaleNumOrders` limit orders between - * `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`, evenly sized - * unless `OrderParams.scaleSkew` weights them along the ladder. + * - `scale`: fan out `OrderParams.scaleNumOrders` limit orders evenly between + * `OrderParams.scaleMinPrice` and `OrderParams.scaleMaxPrice`. * - `chase`: rest a post-only order at the near touch and re-price it as the * touch moves, until it fills or the chase window closes. * diff --git a/packages/perps-controller/src/utils/hyperLiquidValidation.ts b/packages/perps-controller/src/utils/hyperLiquidValidation.ts index 6b27500ad1b..d791982d72a 100644 --- a/packages/perps-controller/src/utils/hyperLiquidValidation.ts +++ b/packages/perps-controller/src/utils/hyperLiquidValidation.ts @@ -522,7 +522,6 @@ export type StrategyOrderValidationParams = { scaleMinPrice?: string; scaleMaxPrice?: string; scaleNumOrders?: number; - scaleSkew?: number; chaseIntervalMs?: number; chaseMaxDurationMs?: number; chaseMaxRepricings?: number; @@ -544,7 +543,6 @@ const STRATEGY_FIELD_OWNER: Record< scaleMinPrice: 'scale', scaleMaxPrice: 'scale', scaleNumOrders: 'scale', - scaleSkew: 'scale', chaseIntervalMs: 'chase', chaseMaxDurationMs: 'chase', chaseMaxRepricings: 'chase', @@ -630,20 +628,6 @@ function validateScaleParams(params: StrategyOrderValidationParams): { }; } - // Omitted is an even ladder, so only a supplied skew is checked. The value is - // taken exactly as the caller wrote it: clients coerce their input to two - // decimals, and rounding it again here would place a ladder weighted - // differently from the one the form previewed. - if ( - params.scaleSkew !== undefined && - (!Number.isFinite(params.scaleSkew) || params.scaleSkew <= 0) - ) { - return { - isValid: false, - error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, - }; - } - return { isValid: true }; } @@ -799,7 +783,6 @@ function validateStrategyOrderParams( * @param params.scaleMinPrice - Lowest price in a scale ladder * @param params.scaleMaxPrice - Highest price in a scale ladder * @param params.scaleNumOrders - How many orders a scale ladder fans out into - * @param params.scaleSkew - How a scale ladder's size is weighted across its rungs * @param params.chaseIntervalMs - How often a chase re-reads the touch * @param params.chaseMaxDurationMs - How long a chase keeps re-pricing * @param params.chaseMaxRepricings - Cap on a chase's cancel/replace cycles diff --git a/packages/perps-controller/src/utils/marketUtils.ts b/packages/perps-controller/src/utils/marketUtils.ts index 5dfd7f7b140..7882859d4f2 100644 --- a/packages/perps-controller/src/utils/marketUtils.ts +++ b/packages/perps-controller/src/utils/marketUtils.ts @@ -6,23 +6,6 @@ import type { import type { CandleData, CandleStick } from '../types/perps-types.js'; import { sortMarkets } from './sortMarkets.js'; -export function clonePerpsMarketData( - markets: PerpsMarketData[], -): PerpsMarketData[] { - return markets.map((market) => ({ - ...market, - ...(market.keywords && { keywords: [...market.keywords] }), - ...(market.tags && { tags: [...market.tags] }), - ...(market.categories && { categories: [...market.categories] }), - ...(market.trend && { - trend: market.trend.map(([timestamp, price]): [number, string] => [ - timestamp, - price, - ]), - }), - })); -} - // ============================================================================ // Market category classification (pure functions) // No service dependencies — pure data transformations that can be tested and diff --git a/packages/perps-controller/src/utils/orderCalculations.ts b/packages/perps-controller/src/utils/orderCalculations.ts index 11cbb9f97d6..5c574fd5ce9 100644 --- a/packages/perps-controller/src/utils/orderCalculations.ts +++ b/packages/perps-controller/src/utils/orderCalculations.ts @@ -438,47 +438,26 @@ export function computeScalePriceLadder(params: { * The split is done in whole units of the asset's size grid rather than in * decimal sizes: dividing and re-flooring in floating point loses a sub-unit of * dust on every rung, and a ladder that submits less than the size that was - * validated is not the order the caller placed. Either allocation below sums to - * exactly `totalSize` in grid units. - * - * This is the one place the ladder's sizes are decided. Clients previewing a - * scale placement must call it rather than reproduce the ramp, or the preview - * and the placement will disagree at the rounding. - * - * **Even (no `skew`, or `skew` exactly 1).** Every rung gets `floor(total / - * count)` units and whatever does not divide evenly goes onto the first rung — - * 11 units across 3 rungs is `5, 3, 3`, not three equal slices. - * - * **Skewed.** Rung weights ramp linearly from 1 at index 0 to `skew` at the last - * index, in ladder order — which is ascending price, `scaleMinPrice` to - * `scaleMaxPrice`, for a buy and a sell alike. Each rung takes - * `floor(weight / sumOfWeights * totalUnits)` units, and the units left over go - * to the rungs with the largest discarded fraction, ties broken by ascending - * index. The leftover is deliberately *not* dumped on the first rung the way the - * even split does it: on a `skew` above 1 that would push size back to the end - * of the ladder the caller weighted away from. + * validated is not the order the caller placed. Whatever does not divide evenly + * goes onto the first rung, so the slices sum to exactly `totalSize`. * * The total is expected to sit on the grid already — `calculateFinalPositionSize` * floors it there — so rounding onto the grid here only absorbs representation * error. A total too small to give every rung a whole unit is rejected: placing - * fewer orders than asked for would silently change the strategy. A `skew` far - * enough from 1 can starve a rung the same way, and is rejected the same way. + * fewer orders than asked for would silently change the strategy. * * @param params - Split parameters. * @param params.totalSize - Total size to distribute. * @param params.count - Number of rungs. * @param params.szDecimals - The asset's size decimal precision. - * @param params.skew - Optional size weighting across the ladder; any finite - * value above 0, used exactly as given. * @returns One size string per rung, in ladder order. */ export function splitScaleSizes(params: { totalSize: number; count: number; szDecimals: number; - skew?: number; }): string[] { - const { totalSize, count, szDecimals, skew } = params; + const { totalSize, count, szDecimals } = params; // Checked here as well as in `computeScalePriceLadder`: this is exported on // its own, and a count of zero would otherwise return an empty split while a @@ -491,13 +470,6 @@ export function splitScaleSizes(params: { throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID); } - // Checked here rather than left to the arithmetic: a non-finite or - // non-positive skew produces weights that are NaN or run negative, and either - // one would come back as a ladder of zero-size rungs instead of a rejection. - if (skew !== undefined && (!Number.isFinite(skew) || skew <= 0)) { - throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); - } - const multiplier = Math.pow(10, szDecimals); const totalUnits = Math.round(totalSize * multiplier); @@ -505,90 +477,16 @@ export function splitScaleSizes(params: { throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); } - const unitsPerRung = - skew === undefined || skew === 1 - ? splitUnitsEvenly({ totalUnits, count }) - : splitUnitsBySkew({ totalUnits, count, skew }); - - // A rung the ramp starved of every unit would be submitted as a zero-size - // order. That is the same failure the total-size check above rejects, and it - // is reported the same way, so a caller reads one reason for "this ladder - // cannot be cut this finely" rather than two. - if (unitsPerRung.some((units) => units === 0)) { - throw new Error(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); - } - - return unitsPerRung.map((units) => - formatHyperLiquidSize({ size: units / multiplier, szDecimals }), - ); -} - -/** - * Spread the ladder's units evenly, leftover on the first rung. - * - * @param params - Split parameters. - * @param params.totalUnits - Total size, in whole size-grid units. - * @param params.count - Number of rungs. - * @returns Units per rung, in ladder order. - */ -function splitUnitsEvenly(params: { - totalUnits: number; - count: number; -}): number[] { - const { totalUnits, count } = params; - const sliceUnits = Math.floor(totalUnits / count); const remainderUnits = totalUnits - sliceUnits * count; return Array.from({ length: count }, (_unused, index) => - index === 0 ? sliceUnits + remainderUnits : sliceUnits, - ); -} - -/** - * Spread the ladder's units along a linear weight ramp. - * - * Flooring every rung leaves up to `count - 1` units unallocated, and they go to - * the rungs that lost the most to the floor — the standard largest-remainder - * allocation. Ties go to the lower index, which keeps the result a function of - * the inputs alone rather than of sort stability. - * - * @param params - Split parameters. - * @param params.totalUnits - Total size, in whole size-grid units. - * @param params.count - Number of rungs. - * @param params.skew - Weight of the last rung relative to the first. - * @returns Units per rung, in ladder order. - */ -function splitUnitsBySkew(params: { - totalUnits: number; - count: number; - skew: number; -}): number[] { - const { totalUnits, count, skew } = params; - - const weights = Array.from( - { length: count }, - (_unused, index) => 1 + ((skew - 1) * index) / (count - 1), + formatHyperLiquidSize({ + size: + (index === 0 ? sliceUnits + remainderUnits : sliceUnits) / multiplier, + szDecimals, + }), ); - const weightSum = weights.reduce((sum, weight) => sum + weight, 0); - - const ideal = weights.map((weight) => (weight / weightSum) * totalUnits); - const unitsPerRung = ideal.map((units) => Math.floor(units)); - const leftoverUnits = - totalUnits - unitsPerRung.reduce((sum, units) => sum + units, 0); - - Array.from({ length: count }, (_unused, index) => index) - .sort((left, right) => { - const fractionDelta = - ideal[right] - unitsPerRung[right] - (ideal[left] - unitsPerRung[left]); - return fractionDelta === 0 ? left - right : fractionDelta; - }) - .slice(0, leftoverUnits) - .forEach((index) => { - unitsPerRung[index] += 1; - }); - - return unitsPerRung; } /** diff --git a/packages/perps-controller/src/utils/orderTypes.ts b/packages/perps-controller/src/utils/orderTypes.ts index 15434042e69..fecb704fe69 100644 --- a/packages/perps-controller/src/utils/orderTypes.ts +++ b/packages/perps-controller/src/utils/orderTypes.ts @@ -252,38 +252,6 @@ export function buildPositionTriggerOrderFromOrder(params: { }; } -/** - * Resolve the scalar TP/SL summary price a position reports for one direction. - * - * The scalar fields are only ever scanned from position-bound triggers, so a - * position whose only take profit (or stop loss) is quantity-scoped reported a - * count of 1 with no price — and a client that renders the scalar showed - * nothing. When the direction has exactly one trigger order, that order is the - * price, whether or not it is position-bound. - * - * Two or more triggers keep the scanned value: no single price describes them, - * and clients render the count instead. Zero triggers keep it too, because it - * still carries the TP/SL of a *pending* order on the market, which the arrays - * deliberately exclude. - * - * @param params - Resolution parameters - * @param params.triggerOrders - Trigger orders attached to the position for one direction - * @param params.scannedPrice - Price scanned from position-bound triggers, if any - * @returns The price to report, or undefined when there is none - */ -export function resolvePositionTriggerSummaryPrice(params: { - triggerOrders: PositionTriggerOrder[]; - scannedPrice?: string; -}): string | undefined { - const { triggerOrders, scannedPrice } = params; - - if (triggerOrders.length === 1) { - return triggerOrders[0].triggerPrice; - } - - return scannedPrice; -} - /** * Build a trigger order type from its two independent dimensions. * diff --git a/packages/perps-controller/src/utils/perpsDiskPersistence.ts b/packages/perps-controller/src/utils/perpsDiskPersistence.ts index 35b10f3de2b..560de2c2657 100644 --- a/packages/perps-controller/src/utils/perpsDiskPersistence.ts +++ b/packages/perps-controller/src/utils/perpsDiskPersistence.ts @@ -42,8 +42,6 @@ export type DiskCacheUserEntry = { orders: Order[]; accountState: AccountState | null; timestamp: number; - hip3ConfigVersion?: number; - dexes?: string[]; }; /** Disk payload shape — either a single entry or a multi-provider wrapper. */ @@ -205,15 +203,19 @@ export function persistMarketEntriesToDisk( * @param diskCache - Disk cache instance from controller infrastructure. * @param entries - Pre-assembled user cache entries to persist. */ -export async function persistUserEntriesToDisk( +export function persistUserEntriesToDisk( diskCache: PerpsDiskCache, entries: DiskCacheUserEntry[], -): Promise { +): void { if (entries.length === 0) { return; } const payload = entries.length === 1 ? entries[0] : { entries }; - await diskCache.setItem(PERPS_DISK_CACHE_USER_DATA, JSON.stringify(payload)); + diskCache + .setItem(PERPS_DISK_CACHE_USER_DATA, JSON.stringify(payload)) + .catch(() => { + // Disk persistence is best-effort and must never block preload. + }); } /** Computed updates returned by hydrateFromDiskSync. */ @@ -227,8 +229,6 @@ export type HydrateFromDiskResult = { accountState: AccountState | null; timestamp: number; address: string; - hip3ConfigVersion?: number; - dexes?: string[]; } >; stats: { @@ -294,23 +294,12 @@ export function hydrateFromDiskSync( if (entry.providerNetworkKey && Array.isArray(entry.data)) { const existing = currentMarketCache[entry.providerNetworkKey]; if (!existing || existing.timestamp < entry.timestamp) { - const strippedData = entry.data.map((market) => { - const structuralMarket = { ...market }; - if ( - structuralMarket.dataSource === - 'terminal-global-snapshot-mark' - ) { - delete structuralMarket.trend; - } - delete structuralMarket.dataSource; - delete structuralMarket.sourceExpiresAt; - return { - ...structuralMarket, - price: PERPS_CONSTANTS.FallbackPriceDisplay, - change24h: PERPS_CONSTANTS.FallbackDataDisplay, - change24hPercent: PERPS_CONSTANTS.FallbackPercentageDisplay, - }; - }); + const strippedData = entry.data.map((market) => ({ + ...market, + price: PERPS_CONSTANTS.FallbackPriceDisplay, + change24h: PERPS_CONSTANTS.FallbackDataDisplay, + change24hPercent: PERPS_CONSTANTS.FallbackPercentageDisplay, + })); marketUpdates[entry.providerNetworkKey] = { data: strippedData, // Disk-hydrated market snapshots are only for structural @@ -349,8 +338,6 @@ export function hydrateFromDiskSync( accountState: entry.accountState, timestamp: Math.min(entry.timestamp, staleHydratedTimestamp), address: entry.address, - hip3ConfigVersion: entry.hip3ConfigVersion, - dexes: entry.dexes, }; userPositions += entry.positions.length; userOrders += entry.orders.length; diff --git a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts index 2d603e754cb..d5de543eb8b 100644 --- a/packages/perps-controller/tests/src/PerpsController.configuration.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.configuration.test.ts @@ -694,7 +694,7 @@ describe('PerpsController', () => { }); describe('pro layout preferences', () => { - it('defaults to collapsed order book, collapsed chart, reserved positions, and positions/orders sort/filter defaults', () => { + it('defaults to collapsed order book, collapsed chart, reserved positions, and positions sort/filter defaults', () => { expect(controller.getProLayoutPreferences()).toEqual({ orderBookExpanded: false, chartExpanded: false, @@ -703,9 +703,6 @@ describe('PerpsController', () => { positionsSideFilter: 'all', positionsSortField: 'positionValue', positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', }); }); @@ -720,9 +717,6 @@ describe('PerpsController', () => { positionsSideFilter: 'all', positionsSortField: 'positionValue', positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', }); }); @@ -735,11 +729,6 @@ describe('PerpsController', () => { positionsSortField: 'unrealizedPnl', positionsSortDirection: 'asc', }); - controller.setProLayoutPreferences({ - ordersSideFilter: 'short', - ordersSortField: 'orderValue', - ordersSortDirection: 'asc', - }); expect(controller.getProLayoutPreferences()).toEqual({ orderBookExpanded: true, @@ -749,9 +738,6 @@ describe('PerpsController', () => { positionsSideFilter: 'long', positionsSortField: 'unrealizedPnl', positionsSortDirection: 'asc', - ordersSideFilter: 'short', - ordersSortField: 'orderValue', - ordersSortDirection: 'asc', }); }); @@ -772,50 +758,6 @@ describe('PerpsController', () => { positionsSideFilter: 'all', positionsSortField: 'unrealizedPnl', positionsSortDirection: 'asc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', - }); - }); - - it('updates orders sort field without clobbering orders sort direction or positions sort', () => { - controller.setProLayoutPreferences({ - ordersSortField: 'size', - ordersSortDirection: 'asc', - }); - controller.setProLayoutPreferences({ - ordersSortField: 'price', - }); - - expect(controller.getProLayoutPreferences()).toEqual({ - orderBookExpanded: false, - chartExpanded: false, - orderBookPosition: 'left', - orderFormPosition: 'right', - positionsSideFilter: 'all', - positionsSortField: 'positionValue', - positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'price', - ordersSortDirection: 'asc', - }); - }); - - it('updates orders side filter without clobbering positions side filter', () => { - controller.setProLayoutPreferences({ positionsSideFilter: 'long' }); - controller.setProLayoutPreferences({ ordersSideFilter: 'short' }); - - expect(controller.getProLayoutPreferences()).toEqual({ - orderBookExpanded: false, - chartExpanded: false, - orderBookPosition: 'left', - orderFormPosition: 'right', - positionsSideFilter: 'long', - positionsSortField: 'positionValue', - positionsSortDirection: 'desc', - ordersSideFilter: 'short', - ordersSortField: 'time', - ordersSortDirection: 'desc', }); }); @@ -841,9 +783,6 @@ describe('PerpsController', () => { positionsSideFilter: 'all', positionsSortField: 'positionValue', positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', }); }); }); diff --git a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts index 97782d6fe34..d781f4b623e 100644 --- a/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.lifecycle.test.ts @@ -24,7 +24,6 @@ import { InitializationState, } from '../../src/PerpsController.js'; import type { PerpsControllerState } from '../../src/PerpsController.js'; -import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; import type { PerpsProvider, @@ -49,7 +48,6 @@ jest.mock( jest.mock('../../src/utils/wait', () => ({ wait: jest.fn().mockResolvedValue(undefined), })); -import { wait as mockWait } from '../../src/utils/wait'; // Mock stream manager const mockStreamManager = { @@ -951,51 +949,6 @@ describe('PerpsController', () => { const provider = controller.getActiveProvider(); expect(provider).toBe(mockProvider); }); - - it('throws plain CLIENT_NOT_INITIALIZED on Failed state (not compound string)', () => { - controller.testSetInitialized(false); - controller.testUpdate((state) => { - state.initializationState = InitializationState.Failed; - state.initializationError = 'WebSocket transport failed'; - }); - - expect(() => controller.getActiveProvider()).toThrow( - PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, - ); - - try { - controller.getActiveProvider(); - } catch (e: any) { - expect(e.message).toBe(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); - expect(e.message).not.toContain(':'); - } - }); - - it('does not log to Sentry when state is Failed', () => { - controller.testSetInitialized(false); - controller.testUpdate((state) => { - state.initializationState = InitializationState.Failed; - state.initializationError = 'WebSocket transport failed'; - }); - - expect(() => controller.getActiveProvider()).toThrow( - PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, - ); - expect(mockInfrastructure.logger.error).not.toHaveBeenCalled(); - }); - - it('does not log to Sentry when initializationError is null on Failed state', () => { - controller.testSetInitialized(false); - controller.testUpdate((state) => { - state.initializationState = InitializationState.Failed; - state.initializationError = null; - }); - - expect(() => controller.getActiveProvider()).toThrow( - PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED, - ); - expect(mockInfrastructure.logger.error).not.toHaveBeenCalled(); - }); }); describe('getActiveProviderOrNull', () => { @@ -1027,81 +980,6 @@ describe('PerpsController', () => { }); }); - describe('action calls during initialization', () => { - it('waits for init to complete before resolving when state is Initializing', async () => { - let resolveBlock!: () => void; - const blockingPromise = new Promise((resolve) => { - resolveBlock = resolve; - }); - - let attempt = 0; - ( - HyperLiquidProvider as jest.MockedClass - ).mockImplementation(() => { - attempt++; - if (attempt === 1) { - throw new Error('Transient failure'); - } - return mockProvider; - }); - - // Block the first retry delay so init stays in Initializing - (mockWait as jest.Mock).mockImplementationOnce(() => blockingPromise); - - const mockOrderResult = { - success: true, - orderId: '123', - status: 'filled', - }; - jest - .spyOn(mockTradingServiceInstance, 'placeOrder') - .mockResolvedValue(mockOrderResult); - - // Start init (will fail first attempt → block on retry wait) - const initPromise = controller.init(); - - // Yield so init reaches the blocking wait - await Promise.resolve(); - await Promise.resolve(); - expect(controller.state.initializationState).toBe( - InitializationState.Initializing, - ); - - // Call placeOrder while init is still in-flight — should not throw - const orderPromise = controller.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - } as any); - - // Unblock init retry so initialization completes - resolveBlock(); - - await initPromise; - const result = await orderPromise; - - expect(result).toEqual(expect.objectContaining({ orderId: '123' })); - }); - - it('throws CLIENT_NOT_INITIALIZED immediately when state is Failed', async () => { - controller.testSetInitialized(false); - controller.testUpdate((state) => { - state.initializationState = InitializationState.Failed; - state.initializationError = 'Network error'; - }); - - await expect( - controller.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - } as any), - ).rejects.toThrow(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); - }); - }); - describe('init', () => { it('initializes providers successfully', async () => { await controller.init(); diff --git a/packages/perps-controller/tests/src/PerpsController.operations.test.ts b/packages/perps-controller/tests/src/PerpsController.operations.test.ts index aeffd0b9d33..2f80d6aee60 100644 --- a/packages/perps-controller/tests/src/PerpsController.operations.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.operations.test.ts @@ -29,7 +29,6 @@ import { } from '../../src/PerpsController.js'; import type { PerpsControllerState } from '../../src/PerpsController.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; -import { RewardsIntegrationService } from '../../src/services/RewardsIntegrationService.js'; import type { GetAvailableDexsParams, PerpsProvider, @@ -828,21 +827,6 @@ describe('PerpsController', () => { }); describe('fee calculations', () => { - it('approves the subscription builder outside order submission', async () => { - mockProvider.approveSubscriptionBuilderFee = jest - .fn() - .mockResolvedValue(true); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - await expect(controller.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - expect(mockProvider.approveSubscriptionBuilderFee).toHaveBeenCalledTimes( - 1, - ); - }); - it('calculates fees', async () => { const feeParams = { orderType: 'market' as const, @@ -877,101 +861,6 @@ describe('PerpsController', () => { context: expect.any(Object), }); }); - - it('passes the cached subscription waiver status to the fee preview', async () => { - const feeParams = { - orderType: 'market' as const, - isMaker: false, - amount: '100000', - symbol: 'BTC', - }; - const waiverStatus = { - eligible: true, - reason: 'eligible' as const, - remainingNotionalUsd: 2500, - }; - const getStatus = jest - .spyOn( - RewardsIntegrationService.prototype, - 'getSubscriptionFeeWaiverStatus', - ) - .mockReturnValue(waiverStatus); - const refresh = jest - .spyOn( - RewardsIntegrationService.prototype, - 'refreshSubscriptionBenefits', - ) - .mockResolvedValue(undefined); - - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - await controller.calculateFees(feeParams); - - expect(refresh).toHaveBeenCalledTimes(1); - expect(refresh.mock.invocationCallOrder[0]).toBeLessThan( - getStatus.mock.invocationCallOrder[0], - ); - expect(mockMarketDataServiceInstance.calculateFees).toHaveBeenCalledWith( - expect.objectContaining({ - context: expect.objectContaining({ - subscriptionFeeWaiver: waiverStatus, - }), - }), - ); - - getStatus.mockRestore(); - refresh.mockRestore(); - }); - - it('exposes subscription benefits invalidation to clients', async () => { - // The service is private to the controller, so a client detecting a - // sign-out or profile switch can only reach it through this method. - const invalidate = jest - .spyOn( - RewardsIntegrationService.prototype, - 'invalidateSubscriptionBenefits', - ) - .mockImplementation(() => undefined); - - controller.invalidateSubscriptionBenefits(); - - expect(invalidate).toHaveBeenCalledTimes(1); - - invalidate.mockRestore(); - }); - - it('omits the subscription waiver from the fee preview when no source is wired', async () => { - const feeParams = { - orderType: 'market' as const, - isMaker: false, - amount: '100000', - symbol: 'BTC', - }; - // The mocked infrastructure wires no `subscription` dependency, so the - // real service reports `no-source` and the context field must be absent - // rather than carrying a meaningless "not eligible". - const getStatus = jest.spyOn( - RewardsIntegrationService.prototype, - 'getSubscriptionFeeWaiverStatus', - ); - - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - await controller.calculateFees(feeParams); - - expect(getStatus).toHaveReturnedWith({ - eligible: false, - reason: 'no-source', - }); - const { context } = ( - mockMarketDataServiceInstance.calculateFees as jest.Mock - ).mock.calls.at(-1)[0]; - expect(context.subscriptionFeeWaiver).toBeUndefined(); - - getStatus.mockRestore(); - }); }); describe('reportOrderToDataLake', () => { diff --git a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts index ec40189984d..e489da65d15 100644 --- a/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts +++ b/packages/perps-controller/tests/src/PerpsController.providers-cache.test.ts @@ -40,16 +40,12 @@ import { import type { PerpsControllerState } from '../../src/PerpsController.js'; import { PERPS_ERROR_CODES } from '../../src/perpsErrorCodes.js'; import { HyperLiquidProvider } from '../../src/providers/HyperLiquidProvider.js'; -import type { ServiceContext } from '../../src/services/ServiceContext.js'; import type { AccountState, GetAvailableDexsParams, PerpsProvider, PerpsPlatformDependencies, - PerpsMarketData, PerpsProviderType, - PerpsUserDataSnapshot, - Position, SubscribeAccountParams, } from '../../src/types/index.js'; import { PerpsAnalyticsEvent } from '../../src/types/index.js'; @@ -388,7 +384,6 @@ describe('PerpsController', () => { let controller: TestablePerpsController; let mockProvider: jest.Mocked; let mockInfrastructure: jest.Mocked; - let mockMessenger: ReturnType; // Helper to mark controller as initialized for tests const markControllerAsInitialized = () => { @@ -580,9 +575,8 @@ describe('PerpsController', () => { }); mockInfrastructure = createMockInfrastructure(); - mockMessenger = createMockMessenger({ call: mockCall }); controller = new TestablePerpsController({ - messenger: mockMessenger, + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), infrastructure: mockInfrastructure, }); @@ -991,147 +985,6 @@ describe('PerpsController', () => { MockedHyperLiquidProvider.mockClear(); }); - it('passes only an exact static Hyperliquid snapshot identity and guards config races', async () => { - mockInfrastructure.terminalApi = { - ...mockInfrastructure.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', - }; - controller = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - clientConfig: { - fallbackHip3Enabled: true, - fallbackHip3AllowlistMarkets: ['xyz:*'], - fallbackHip3BlocklistMarkets: ['xyz:TSLA'], - }, - infrastructure: mockInfrastructure, - }); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.testMarkInitialized(); - mockProvider.getMarketDataWithPrices.mockResolvedValue([]); - - await controller.getMarketDataWithPrices({ - standalone: true, - useTerminalApi: true, - }); - - const call = mockMarketDataServiceInstance.getMarketDataWithPrices.mock - .calls[0]?.[0] as { - context: ServiceContext; - }; - expect(call.context.globalSnapshot?.request).toStrictEqual({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main', 'xyz'], - }); - expect(call.context.globalSnapshot?.isMarketAllowed('xyz:GOLD')).toBe( - true, - ); - expect(call.context.globalSnapshot?.isMarketAllowed('xyz:TSLA')).toBe( - false, - ); - expect(call.context.globalSnapshot?.isCurrent()).toBe(true); - - controller.testUpdate((state) => { - state.hip3ConfigVersion += 1; - }); - expect(call.context.globalSnapshot?.isCurrent()).toBe(false); - - const liveUpdate = { - symbol: 'BTC', - price: '50002', - timestamp: Date.now(), - isTradable: true, - }; - mockProvider.subscribeToPrices.mockImplementation(({ callback }) => { - callback([liveUpdate]); - return jest.fn(); - }); - const priceCallback = jest.fn(); - controller.subscribeToPrices({ - symbols: ['BTC'], - callback: priceCallback, - }); - expect(mockProvider.subscribeToPrices).toHaveBeenCalledTimes(1); - expect(priceCallback).toHaveBeenCalledWith([liveUpdate]); - }); - - it('treats a bare allowlist entry as a DEX shorthand', async () => { - mockInfrastructure.terminalApi = { - ...mockInfrastructure.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', - }; - controller = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - clientConfig: { - fallbackHip3Enabled: true, - fallbackHip3AllowlistMarkets: ['xyz'], - }, - infrastructure: mockInfrastructure, - }); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.testMarkInitialized(); - mockProvider.getMarketDataWithPrices.mockResolvedValue([]); - - await controller.getMarketDataWithPrices({ - standalone: true, - useTerminalApi: true, - }); - - expect( - mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] - .context.globalSnapshot?.request.enabledDexes, - ).toStrictEqual(['main', 'xyz']); - }); - - it('keeps main first in an exact static snapshot identity', async () => { - mockInfrastructure.terminalApi = { - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', - }; - controller = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - clientConfig: { - fallbackHip3Enabled: true, - fallbackHip3AllowlistMarkets: ['flx:*', 'xyz:*'], - }, - infrastructure: mockInfrastructure, - }); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.testMarkInitialized(); - - await controller.getMarketDataWithPrices({ standalone: true }); - - expect( - mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] - .context.globalSnapshot?.request.enabledDexes, - ).toEqual(['main', 'flx', 'xyz']); - }); - - it('does not enable snapshots for a legacy-only injected Terminal service', async () => { - mockInfrastructure.terminalApi = undefined; - mockInfrastructure.terminalMarketService = { - fetchMarkets: jest.fn(), - clearCache: jest.fn(), - logError: jest.fn(), - }; - controller = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: mockInfrastructure, - }); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.testMarkInitialized(); - - await controller.getMarketDataWithPrices({ standalone: true }); - - expect( - mockMarketDataServiceInstance.getMarketDataWithPrices.mock.calls[0]?.[0] - .context.globalSnapshot, - ).toBeUndefined(); - }); - it('uses existing provider for standalone queries when available', async () => { const mockMarketData = [ { @@ -1198,33 +1051,6 @@ describe('PerpsController', () => { expect(result).toEqual(mockMarketData); }); - it('does not disconnect a standalone provider while a market request is in flight', async () => { - let resolveMarketData!: (marketData: unknown[]) => void; - const marketDataPromise = new Promise((resolve) => { - resolveMarketData = resolve; - }); - const tempMockProvider = createMockHyperLiquidProvider(); - tempMockProvider.getMarketDataWithPrices.mockReturnValue( - marketDataPromise as ReturnType< - typeof tempMockProvider.getMarketDataWithPrices - >, - ); - MockedHyperLiquidProvider.mockImplementation(() => tempMockProvider); - - const marketRequest = controller.getMarketDataWithPrices({ - standalone: true, - }); - const disconnectRequest = controller.disconnect(); - - expect(tempMockProvider.disconnect).not.toHaveBeenCalled(); - - resolveMarketData([]); - - await expect(marketRequest).resolves.toEqual([]); - await disconnectRequest; - expect(tempMockProvider.disconnect).toHaveBeenCalledTimes(1); - }); - it('uses getActiveProvider for non-standalone queries', async () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); @@ -1310,25 +1136,6 @@ describe('PerpsController', () => { expect(callCountAfter).toBe(callCountBefore); }); - it('stopMarketDataPreload drops a queued trailing refresh', async () => { - let resolvePreload!: (value: PerpsMarketData[]) => void; - mockProvider.getMarketDataWithPrices.mockReturnValue( - new Promise((resolve) => { - resolvePreload = resolve; - }), - ); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - controller.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(5 * 60 * 1000); - controller.stopMarketDataPreload(); - resolvePreload([]); - await jest.advanceTimersByTimeAsync(0); - - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - }); - it('stopMarketDataPreload is safe to call when not started', () => { expect(() => controller.stopMarketDataPreload()).not.toThrow(); }); @@ -1384,64 +1191,6 @@ describe('PerpsController', () => { ).toHaveLength(1); }); - it('publishes one construction timestamp after disk hydration and does not write Sentry at construct', () => { - const infra = createMockInfrastructure(); - (infra.performance.now as jest.Mock).mockReturnValue(321); - const onControllerConstructed = jest.fn(); - infra.performance.onControllerConstructed = onControllerConstructed; - - new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: infra, - }); - - expect(onControllerConstructed).toHaveBeenCalledTimes(1); - expect(onControllerConstructed).toHaveBeenCalledWith(321); - expect( - (infra.diskCache.getItemSync as jest.Mock).mock.invocationCallOrder[0], - ).toBeLessThan(onControllerConstructed.mock.invocationCallOrder[0]); - expect(infra.tracer.setMeasurement).not.toHaveBeenCalled(); - }); - - it('does not hydrate expired Terminal trend provenance from disk', () => { - const infra = createMockInfrastructure(); - (infra.diskCache.getItemSync as jest.Mock).mockImplementation( - (key: string) => - key === PERPS_DISK_CACHE_MARKETS - ? JSON.stringify({ - providerNetworkKey: 'hyperliquid:mainnet', - data: [ - { - symbol: 'BTC', - name: 'Bitcoin', - price: '50000', - change24h: '+100', - change24hPercent: '+0.2%', - maxLeverage: '50x', - volume: '$1B', - dataSource: 'terminal-global-snapshot-mark', - sourceExpiresAt: Date.now() - 1, - trend: [[Date.now() - 3_600_000, '49000']], - }, - ], - timestamp: Date.now(), - }) - : null, - ); - - const ctrl = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: infra, - }); - - expect( - ctrl.state.cachedMarketDataByProvider['hyperliquid:mainnet']?.data[0] - .trend, - ).toBeUndefined(); - }); - it('hydrates multi-provider market data from disk before providers register', () => { const timestamp = Date.now(); const diskMarkets = { @@ -1533,8 +1282,6 @@ describe('PerpsController', () => { providerId: 'hyperliquid', }, timestamp, - hip3ConfigVersion: 0, - dexes: ['main'], }, { providerNetworkKey: 'myx:mainnet', @@ -1620,95 +1367,6 @@ describe('PerpsController', () => { ); }); - it('rejects a disk user snapshot with a mismatched HIP-3 identity', () => { - const diskUserData = { - providerNetworkKey: 'hyperliquid:mainnet', - address: '0x1234567890abcdef1234567890abcdef12345678', - positions: [createMockPosition()], - orders: [], - accountState: null, - timestamp: Date.now(), - hip3ConfigVersion: 9, - dexes: ['main'], - }; - const infra = createMockInfrastructure(); - (infra.diskCache.getItemSync as jest.Mock).mockImplementation( - (key: string) => - key === PERPS_DISK_CACHE_USER_DATA - ? JSON.stringify(diskUserData) - : null, - ); - const ctrl = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: infra, - }); - - const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); - - expect(result).toBeNull(); - }); - - it('rejects malformed disk DEX identity without throwing', () => { - const diskUserData = { - providerNetworkKey: 'hyperliquid:mainnet', - address: '0x1234567890abcdef1234567890abcdef12345678', - positions: [createMockPosition()], - orders: [], - accountState: null, - timestamp: Date.now(), - hip3ConfigVersion: 0, - dexes: { length: 1 }, - }; - const infra = createMockInfrastructure(); - (infra.diskCache.getItemSync as jest.Mock).mockImplementation( - (key: string) => - key === PERPS_DISK_CACHE_USER_DATA - ? JSON.stringify(diskUserData) - : null, - ); - const ctrl = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: infra, - }); - - const readCache = () => - ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); - - expect(readCache).not.toThrow(); - expect(readCache()).toBeNull(); - }); - - it('accepts a disk user snapshot with the current exact HIP-3 identity', () => { - const diskUserData = { - providerNetworkKey: 'hyperliquid:mainnet', - address: '0x1234567890abcdef1234567890abcdef12345678', - positions: [createMockPosition()], - orders: [], - accountState: null, - timestamp: Date.now(), - hip3ConfigVersion: 0, - dexes: ['main'], - }; - const infra = createMockInfrastructure(); - (infra.diskCache.getItemSync as jest.Mock).mockImplementation( - (key: string) => - key === PERPS_DISK_CACHE_USER_DATA - ? JSON.stringify(diskUserData) - : null, - ); - const ctrl = new TestablePerpsController({ - messenger: createMockMessenger(), - state: getDefaultPerpsControllerState(), - infrastructure: infra, - }); - - const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); - - expect(result?.positions).toHaveLength(1); - }); - it('hydrates user data from disk even when address differs (filtered at read time)', () => { const diskUserData = { providerNetworkKey: 'hyperliquid:mainnet', @@ -1830,7 +1488,7 @@ describe('PerpsController', () => { jest.useRealTimers(); }); - it('writes returned global snapshot data into the provider preload cache', async () => { + it('updates cachedMarketData in state', async () => { const mockData = [ { symbol: 'BTC', @@ -1840,8 +1498,6 @@ describe('PerpsController', () => { change24h: '+100', change24hPercent: '+0.2%', volume: '$1B', - dataSource: 'terminal-global-snapshot-mark' as const, - sourceExpiresAt: Date.now() + 20_000, }, ]; markControllerAsInitialized(); @@ -1854,166 +1510,9 @@ describe('PerpsController', () => { const entry = controller.state.cachedMarketDataByProvider['hyperliquid:mainnet']; expect(entry?.data).toEqual(mockData); - expect(entry?.data[0]?.dataSource).toBe('terminal-global-snapshot-mark'); - expect(entry?.sourceExpiresAt).toBe(mockData[0].sourceExpiresAt); - expect(entry?.hip3ConfigVersion).toBe(0); - expect(entry?.dexes).toEqual(['main']); expect(entry?.timestamp).toBeGreaterThan(0); }); - it('refreshes a source-expired snapshot inside the normal preload guard', async () => { - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.testUpdate((state) => { - state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { - data: [{ symbol: 'BTC', name: 'BTC', price: '$1' }], - timestamp: Date.now(), - sourceExpiresAt: Date.now() - 1, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - mockMarketDataServiceInstance.getMarketDataWithPrices.mockResolvedValue( - [], - ); - - controller.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - - expect( - mockMarketDataServiceInstance.getMarketDataWithPrices, - ).toHaveBeenCalledTimes(1); - }); - - it('does not seed memory or disk when snapshot context changes during preload', async () => { - let resolveSnapshot: - | (( - value: Awaited< - ReturnType - >, - ) => void) - | undefined; - mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( - () => - new Promise((resolve) => { - resolveSnapshot = resolve; - }), - ); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - controller.startMarketDataPreload(); - await Promise.resolve(); - controller.testUpdate((state) => { - state.isTestnet = true; - state.hip3ConfigVersion += 1; - }); - resolveSnapshot?.([ - { - symbol: 'BTC', - name: 'Bitcoin', - price: '$50000.00', - maxLeverage: '50x', - change24h: '+$125.00', - change24hPercent: '0.25%', - volume: '$1000000', - dataSource: 'terminal-global-snapshot-mark', - }, - ]); - await jest.advanceTimersByTimeAsync(100); - - expect( - controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], - ).toBeUndefined(); - expect( - controller.state.cachedMarketDataByProvider['hyperliquid:testnet'], - ).toBeUndefined(); - expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); - }); - - it('does not seed a provider fallback when context changes during preload', async () => { - let resolveProvider: - | (( - value: Awaited< - ReturnType - >, - ) => void) - | undefined; - mockMarketDataServiceInstance.getMarketDataWithPrices.mockImplementation( - () => - new Promise((resolve) => { - resolveProvider = resolve; - }), - ); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - - controller.startMarketDataPreload(); - await Promise.resolve(); - controller.testUpdate((state) => { - state.hip3ConfigVersion += 1; - }); - resolveProvider?.([ - { - symbol: 'BTC', - name: 'Bitcoin', - price: '$50000.00', - maxLeverage: '50x', - change24h: '+$125.00', - change24hPercent: '0.25%', - volume: '$1000000', - }, - ]); - await jest.advanceTimersByTimeAsync(100); - - expect( - controller.state.cachedMarketDataByProvider['hyperliquid:mainnet'], - ).toBeUndefined(); - expect(mockInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); - }); - - it('runs the latest network preload after an older request completes', async () => { - let resolveMainnet!: (value: PerpsMarketData[]) => void; - mockMarketDataServiceInstance.getMarketDataWithPrices - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveMainnet = resolve; - }), - ) - .mockResolvedValueOnce([ - { - symbol: 'BTC', - name: 'Bitcoin', - price: '$50000', - }, - ]); - markControllerAsInitialized(); - controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); - controller.startMarketDataPreload(); - await Promise.resolve(); - - controller.testUpdate((state) => { - state.isTestnet = true; - }); - const stateChangedHandler = mockMessenger.subscribe.mock.calls.find( - ([event]) => event === 'PerpsController:stateChanged', - )?.[1]; - stateChangedHandler?.(controller.state, [ - { op: 'replace', path: ['isTestnet'], value: true }, - ]); - resolveMainnet([]); - await jest.advanceTimersByTimeAsync(100); - - expect( - mockMarketDataServiceInstance.getMarketDataWithPrices, - ).toHaveBeenCalledTimes(2); - expect( - controller.state.cachedMarketDataByProvider['hyperliquid:testnet'] - ?.data[0].symbol, - ).toBe('BTC'); - }); - it('persists preloaded market data to disk', async () => { const mockData = [ { @@ -2135,13 +1634,6 @@ describe('PerpsController', () => { expect(mockInfrastructure.tracer.trace).toHaveBeenCalled(); expect(mockInfrastructure.tracer.endTrace).toHaveBeenCalled(); expect(mockInfrastructure.tracer.setMeasurement).toHaveBeenCalled(); - const traceId = mockInfrastructure.tracer.trace.mock.calls[0][0].id; - expect(mockInfrastructure.tracer.setMeasurement).toHaveBeenCalledWith( - expect.any(String), - expect.any(Number), - 'millisecond', - traceId, - ); }); }); @@ -2166,36 +1658,6 @@ describe('PerpsController', () => { let preloadController: TestablePerpsController; let preloadMockProvider: jest.Mocked; let preloadInfrastructure: jest.Mocked; - let preloadMessenger: ReturnType; - - const createUserSnapshot = (): PerpsUserDataSnapshot => ({ - positions: [createMockPosition()], - orders: [], - accountState: { - totalBalance: '10000', - spendableBalance: '10000', - withdrawableBalance: '10000', - marginUsed: '0', - unrealizedPnl: '0', - returnOnEquity: '0', - }, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - address: mockEvmAccount.address, - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - - const createDeferredSnapshot = () => { - let resolve!: (value: PerpsUserDataSnapshot) => void; - const promise = new Promise((promiseResolve) => { - resolve = promiseResolve; - }); - - return { promise, resolve }; - }; beforeEach(() => { jest.useFakeTimers(); @@ -2228,563 +1690,21 @@ describe('PerpsController', () => { }); preloadMockProvider.getMarkets.mockResolvedValue([]); preloadMockProvider.getOpenOrders.mockResolvedValue([]); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockImplementation(async ({ userAddress, identity }) => { - const [positions, orders, accountState] = await Promise.all([ - preloadMockProvider.getPositions({ - standalone: true, - userAddress, - }), - preloadMockProvider.getOpenOrders({ - standalone: true, - userAddress, - }), - preloadMockProvider.getAccountState({ - standalone: true, - userAddress, - }), - ]); - - return { - positions, - orders, - accountState, - identity: { - ...identity, - address: userAddress, - dexes: ['main'], - }, - }; - }); ( HyperLiquidProvider as jest.MockedClass ).mockImplementation(() => preloadMockProvider); - preloadMessenger = createMockMessenger({ call: mockCall }); preloadController = new TestablePerpsController({ - messenger: preloadMessenger, + messenger: createMockMessenger({ call: mockCall }), state: getDefaultPerpsControllerState(), infrastructure: preloadInfrastructure, }); }); afterEach(() => { - mockEvmAccount.address = '0x1234567890123456789012345678901234567890'; preloadController.stopMarketDataPreload(); jest.useRealTimers(); }); - it('returns and atomically persists a provider user snapshot', async () => { - const snapshot = createUserSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(snapshot); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - const result = await preloadController.getUserDataSnapshot(); - - expect(result).toEqual(snapshot); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - positions: snapshot.positions, - orders: snapshot.orders, - accountState: snapshot.accountState, - address: mockEvmAccount.address, - }), - ); - expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); - }); - - it('keeps the returned user snapshot mutable without mutating the cache', async () => { - const snapshot = createUserSnapshot(); - snapshot.accountState.subAccountBreakdown = { - main: { - spendableBalance: '10', - withdrawableBalance: '10', - totalBalance: '10', - }, - }; - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(snapshot); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - const result = await preloadController.getUserDataSnapshot(); - result.positions[0].leverage.value = 99; - const breakdown = result.accountState.subAccountBreakdown; - if (!breakdown) { - throw new Error('Expected sub-account breakdown'); - } - breakdown.main.totalBalance = '99'; - - const cached = - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet']; - expect(cached.positions[0].leverage.value).not.toBe(99); - expect(cached.accountState?.subAccountBreakdown?.main.totalBalance).toBe( - '10', - ); - }); - - it('fetches through the standalone provider before an active instance exists', async () => { - const snapshot = createUserSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(snapshot); - - const result = await preloadController.getUserDataSnapshot(); - - expect(result).toEqual(snapshot); - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith({ - userAddress: mockEvmAccount.address, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - }); - - it('rejects a snapshot whose DEX identity differs from captured configuration', async () => { - const snapshot = createUserSnapshot(); - snapshot.identity.dexes = ['main', 'xyz']; - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(snapshot); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( - 'mismatched', - ); - - expect(preloadController.state.cachedUserDataByProvider).toEqual({}); - expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); - }); - - it('coalesces concurrent requests with the same captured identity', async () => { - const deferred = createDeferredSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockReturnValue(deferred.promise); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - const firstRequest = preloadController.getUserDataSnapshot(); - const secondRequest = preloadController.getUserDataSnapshot(); - deferred.resolve(createUserSnapshot()); - - await expect(Promise.all([firstRequest, secondRequest])).resolves.toEqual( - [createUserSnapshot(), createUserSnapshot()], - ); - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); - }); - - it('preloads a bare DEX allowlist through the atomic snapshot path', async () => { - preloadController = new TestablePerpsController({ - messenger: preloadMessenger, - state: getDefaultPerpsControllerState(), - clientConfig: { - fallbackHip3Enabled: true, - fallbackHip3AllowlistMarkets: ['xyz'], - }, - infrastructure: preloadInfrastructure, - }); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockImplementation(async ({ userAddress, identity }) => ({ - ...createUserSnapshot(), - identity: { - ...identity, - address: userAddress, - dexes: ['main', 'xyz'], - }, - })); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - identity: expect.objectContaining({ dexes: ['main', 'xyz'] }), - }), - ); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] - ?.dexes, - ).toStrictEqual(['main', 'xyz']); - }); - - it('does not coalesce requests across provider instances', async () => { - const deferred = createDeferredSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockReturnValue(deferred.promise); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - const firstRequest = preloadController.getUserDataSnapshot(); - const replacementProvider = createMockHyperLiquidProvider(); - replacementProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(createUserSnapshot()); - preloadController.testSetProviders( - new Map([['hyperliquid', replacementProvider]]), - ); - - await expect(preloadController.getUserDataSnapshot()).resolves.toEqual( - createUserSnapshot(), - ); - deferred.resolve(createUserSnapshot()); - await expect(firstRequest).rejects.toThrow('context changed'); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - expect(replacementProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - }); - - it('serializes disk writes so an older account cannot overwrite a newer one', async () => { - let resolveFirstWrite!: () => void; - const firstWrite = new Promise((resolve) => { - resolveFirstWrite = resolve; - }); - preloadInfrastructure.diskCache.setItem - .mockReturnValueOnce(firstWrite) - .mockResolvedValue(undefined); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockImplementation(async () => createUserSnapshot()); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - await preloadController.getUserDataSnapshot(); - mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; - await preloadController.getUserDataSnapshot(); - expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(1); - - resolveFirstWrite(); - await jest.advanceTimersByTimeAsync(0); - - expect(preloadInfrastructure.diskCache.setItem).toHaveBeenCalledTimes(2); - const lastPayload = JSON.parse( - preloadInfrastructure.diskCache.setItem.mock.calls[1][1] as string, - ) as { address: string }; - expect(lastPayload.address).toBe(mockEvmAccount.address); - }); - - it('refreshes user data while WebSocket is connected independently of market preload', async () => { - const snapshot = createUserSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockResolvedValue(snapshot); - preloadMockProvider.getMarketDataWithPrices.mockReturnValue( - new Promise(() => undefined), - ); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Connected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'] - ?.positions, - ).toEqual(snapshot.positions); - }); - - it('queues the selected-account refresh when an older preload is in flight', async () => { - const firstSnapshot = createUserSnapshot(); - const firstRequest = createDeferredSnapshot(); - const secondAddress = '0x9999999999999999999999999999999999999999'; - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockReturnValueOnce(firstRequest.promise) - .mockImplementationOnce(async () => createUserSnapshot()); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - preloadController.startMarketDataPreload(); - await Promise.resolve(); - const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( - ([event]) => event === 'AccountsController:selectedAccountChange', - )?.[1] as (() => void) | undefined; - mockEvmAccount.address = secondAddress; - accountChangeHandler?.(); - firstRequest.resolve(firstSnapshot); - await jest.advanceTimersByTimeAsync(100); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(2); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual(expect.objectContaining({ address: secondAddress })); - }); - - it('does not poll user REST data when WebSocket and a matching cache are available', async () => { - preloadMockProvider.getUserDataSnapshot = jest.fn(); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Connected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.testUpdate((state) => { - state.cachedUserDataByProvider['hyperliquid:mainnet'] = { - positions: [], - orders: [], - accountState: createUserSnapshot().accountState, - timestamp: 1, - address: mockEvmAccount.address, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - - expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); - }); - - it('refreshes once after HIP-3 identity changes while WebSocket is connected', async () => { - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockImplementation(async ({ userAddress, identity }) => ({ - ...createUserSnapshot(), - identity: { - ...identity, - address: userAddress, - dexes: ['main'], - }, - })); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Connected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.testUpdate((state) => { - state.cachedUserDataByProvider['hyperliquid:mainnet'] = { - positions: [], - orders: [], - accountState: createUserSnapshot().accountState, - timestamp: Date.now(), - address: mockEvmAccount.address, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - preloadMockProvider.getUserDataSnapshot.mockClear(); - - preloadController.testUpdate((state) => { - state.hip3ConfigVersion = 1; - }); - await jest.advanceTimersByTimeAsync(100); - await jest.advanceTimersByTimeAsync(300_000); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - hip3ConfigVersion: 1, - dexes: ['main'], - }), - ); - }); - - it('starts user preload after network reinitialization completes', async () => { - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - preloadMockProvider.getUserDataSnapshot.mockClear(); - jest.spyOn(preloadController, 'init').mockImplementationOnce(async () => { - preloadController.testUpdate((state) => { - state.initializationState = InitializationState.Initialized; - }); - }); - - await preloadController.toggleTestnet(); - await jest.advanceTimersByTimeAsync(100); - - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(1); - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledWith( - expect.objectContaining({ - identity: expect.objectContaining({ network: 'testnet' }), - }), - ); - }); - - it('does not put userAddress on user-preload trace data and targets the named trace id', async () => { - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(100); - - const userPreloadTrace = preloadInfrastructure.tracer.trace.mock.calls - .map((call) => call[0]) - .find((params) => params.name === 'Perps User Data Preload'); - expect(userPreloadTrace).toBeDefined(); - expect(userPreloadTrace?.data).toBeUndefined(); - expect(JSON.stringify(userPreloadTrace)).not.toContain( - mockEvmAccount.address, - ); - expect(preloadInfrastructure.tracer.setMeasurement).toHaveBeenCalledWith( - expect.any(String), - expect.any(Number), - 'millisecond', - userPreloadTrace?.id, - ); - }); - - it.each([ - [ - 'provider', - () => - preloadController.testUpdate((state) => { - state.activeProvider = 'myx'; - }), - ], - [ - 'network', - () => - preloadController.testUpdate((state) => { - state.isTestnet = true; - }), - ], - [ - 'HIP-3 configuration', - () => - preloadController.testUpdate((state) => { - state.hip3ConfigVersion += 1; - }), - ], - [ - 'selected address', - () => { - mockEvmAccount.address = '0x9999999999999999999999999999999999999999'; - }, - ], - ])('discards a user snapshot after a %s change', async (_label, mutate) => { - const deferred = createDeferredSnapshot(); - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockReturnValue(deferred.promise); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - const request = preloadController.getUserDataSnapshot(); - await Promise.resolve(); - mutate(); - deferred.resolve(createUserSnapshot()); - - await expect(request).rejects.toThrow('context changed'); - expect(preloadController.state.cachedUserDataByProvider).toEqual({}); - expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); - }); - - it('preserves last-known-good data when a snapshot request fails', async () => { - const lastKnownGood = { - positions: [createMockPosition({ symbol: 'ETH' })], - orders: [], - accountState: null, - timestamp: 1, - address: mockEvmAccount.address, - }; - preloadMockProvider.getUserDataSnapshot = jest - .fn() - .mockRejectedValue(new Error('partial snapshot')); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.testUpdate((state) => { - state.cachedUserDataByProvider['hyperliquid:mainnet'] = lastKnownGood; - }); - - await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( - 'partial snapshot', - ); - - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual(lastKnownGood); - expect(preloadInfrastructure.diskCache.setItem).not.toHaveBeenCalled(); - }); - - it('fails closed when the provider has no atomic snapshot API', async () => { - preloadMockProvider.getUserDataSnapshot = undefined; - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - - await expect(preloadController.getUserDataSnapshot()).rejects.toThrow( - 'atomic snapshot API', - ); - - expect(preloadMockProvider.getPositions).not.toHaveBeenCalled(); - expect(preloadMockProvider.getOpenOrders).not.toHaveBeenCalled(); - expect(preloadMockProvider.getAccountState).not.toHaveBeenCalled(); - }); - it('fetches positions, orders, and account state', async () => { const mockPositions = [createMockPosition()]; const mockOrders = [ @@ -2846,123 +1766,6 @@ describe('PerpsController', () => { expect(entry.timestamp).toBeGreaterThan(0); }); - it('keeps aggregated mode on the legacy provider path with trusted identity', async () => { - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.testUpdate((state) => { - state.activeProvider = 'aggregated'; - }); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(500); - - expect(preloadMockProvider.getUserDataSnapshot).not.toHaveBeenCalled(); - expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(1); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - hip3ConfigVersion: 0, - dexes: ['main'], - }), - ); - expect( - preloadController.getCachedUserDataForActiveProvider({ - skipTTL: true, - }), - ).not.toBeNull(); - }); - - it('stamps Hyperliquid identity for aggregated preload before initialization', async () => { - preloadController.testUpdate((state) => { - state.activeProvider = 'aggregated'; - }); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - - preloadController.startMarketDataPreload(); - await jest.advanceTimersByTimeAsync(500); - - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - hip3ConfigVersion: 0, - dexes: ['main'], - }), - ); - }); - - it('discards an aggregated preload after HIP-3 context changes', async () => { - let resolveOldPositions!: (value: Position[]) => void; - preloadMockProvider.getPositions - .mockReturnValueOnce( - new Promise((resolve) => { - resolveOldPositions = resolve; - }), - ) - .mockResolvedValueOnce([ - createMockPosition({ - symbol: 'NEW', - providerId: 'hyperliquid', - }), - ]); - preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); - preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadController.testMarkInitialized(); - preloadController.testSetProviders( - new Map([['hyperliquid', preloadMockProvider]]), - ); - preloadController.testUpdate((state) => { - state.activeProvider = 'aggregated'; - }); - - preloadController.startMarketDataPreload(); - await Promise.resolve(); - preloadController.testUpdate((state) => { - state.hip3ConfigVersion = 1; - }); - const stateChangeHandler = preloadMessenger.subscribe.mock.calls.find( - ([event]) => event === 'PerpsController:stateChanged', - )?.[1] as - | (( - state: PerpsControllerState, - patches: { path: (string | number)[] }[], - ) => void) - | undefined; - stateChangeHandler?.(preloadController.state, [ - { path: ['hip3ConfigVersion'] }, - ]); - resolveOldPositions([ - createMockPosition({ - symbol: 'OLD', - providerId: 'hyperliquid', - }), - ]); - await jest.advanceTimersByTimeAsync(500); - - expect(preloadMockProvider.getPositions).toHaveBeenCalledTimes(2); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - positions: [expect.objectContaining({ symbol: 'NEW' })], - hip3ConfigVersion: 1, - dexes: ['main'], - }), - ); - }); - it('persists preloaded user data to disk', async () => { const mockPositions = [createMockPosition()]; const mockOrders = [ @@ -3033,108 +1836,25 @@ describe('PerpsController', () => { expect(persistedPayload.timestamp).toBeGreaterThan(0); }); - it('replaces the provider cache when the selected account changes', async () => { - const firstAddress = mockEvmAccount.address; - const secondAddress = '0x9999999999999999999999999999999999999999'; + it('skips when WebSocket is connected', async () => { preloadController.testMarkInitialized(); preloadController.testSetProviders( new Map([['hyperliquid', preloadMockProvider]]), ); preloadMockProvider.getMarketDataWithPrices.mockResolvedValue([]); preloadMockProvider.getWebSocketConnectionState.mockReturnValue( - WSState.Disconnected, - ); - preloadMockProvider.getPositions.mockImplementation( - async ({ userAddress }) => [ - createMockPosition({ - symbol: - userAddress.toLowerCase() === firstAddress.toLowerCase() - ? 'BTC' - : 'ETH', - }), - ], + WSState.Connected, ); preloadController.startMarketDataPreload(); await jest.advanceTimersByTimeAsync(500); - const accountChangeHandler = preloadMessenger.subscribe.mock.calls.find( - ([event]) => event === 'AccountsController:selectedAccountChange', - )?.[1] as (() => void) | undefined; - expect(accountChangeHandler).toBeDefined(); - - mockEvmAccount.address = secondAddress; - accountChangeHandler?.(); - await jest.advanceTimersByTimeAsync(500); - expect( - preloadController.state.cachedUserDataByProvider['hyperliquid:mainnet'], - ).toEqual( - expect.objectContaining({ - address: secondAddress, - hip3ConfigVersion: 0, - dexes: ['main'], - }), + expect(preloadInfrastructure.debugLogger.log).toHaveBeenCalledWith( + 'PerpsController: Skipping user data preload \u2014 WebSocket connected', ); expect( - preloadController.getCachedUserDataForActiveProvider({ - skipTTL: true, - }), - ).toEqual( - expect.objectContaining({ - positions: [expect.objectContaining({ symbol: 'ETH' })], - }), - ); - - mockEvmAccount.address = firstAddress; - accountChangeHandler?.(); - - expect( - preloadController.getCachedUserDataForActiveProvider({ - skipTTL: true, - }), - ).toBeNull(); - await jest.advanceTimersByTimeAsync(500); - expect( - preloadController.getCachedUserDataForActiveProvider({ - skipTTL: true, - })?.positions[0].symbol, - ).toBe('BTC'); - expect(preloadMockProvider.getUserDataSnapshot).toHaveBeenCalledTimes(3); - expect(preloadInfrastructure.diskCache.removeItem).not.toHaveBeenCalled(); - - const userWrites = ( - preloadInfrastructure.diskCache.setItem as jest.Mock - ).mock.calls.filter(([key]) => key === PERPS_DISK_CACHE_USER_DATA); - const retainedPayload = JSON.parse( - userWrites[userWrites.length - 1][1] as string, - ) as { address: string }; - expect(retainedPayload.address.toLowerCase()).toBe( - firstAddress.toLowerCase(), - ); - - const hydratedInfrastructure = createMockInfrastructure(); - hydratedInfrastructure.diskCache.getItemSync.mockImplementation((key) => - key === PERPS_DISK_CACHE_USER_DATA - ? JSON.stringify(retainedPayload) - : null, - ); - const hydratedController = new TestablePerpsController({ - messenger: preloadMessenger, - state: getDefaultPerpsControllerState(), - infrastructure: hydratedInfrastructure, - }); - - expect( - hydratedController.getCachedUserDataForActiveProvider({ - skipTTL: true, - })?.positions[0].symbol, - ).toBe('BTC'); - mockEvmAccount.address = secondAddress; - expect( - hydratedController.getCachedUserDataForActiveProvider({ - skipTTL: true, - }), - ).toBeNull(); + Object.keys(preloadController.state.cachedUserDataByProvider), + ).toHaveLength(0); }); it('handles errors without throwing', async () => { @@ -3288,60 +2008,6 @@ describe('PerpsController', () => { }); describe('getCachedMarketDataForActiveProvider', () => { - it('rejects an expired Terminal snapshot even when TTL is skipped', () => { - controller.testUpdate((state) => { - state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { - data: [ - { - symbol: 'BTC', - name: 'BTC', - price: '$50000', - dataSource: 'terminal-global-snapshot-mark', - sourceExpiresAt: Date.now() - 1, - }, - ], - timestamp: Date.now(), - sourceExpiresAt: Date.now() - 1, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - - expect( - controller.getCachedMarketDataForActiveProvider({ skipTTL: true }), - ).toBeNull(); - }); - - it('returns defensive copies of current Terminal snapshot data', () => { - const expiresAt = Date.now() + 20_000; - controller.testUpdate((state) => { - state.cachedMarketDataByProvider['hyperliquid:mainnet'] = { - data: [ - { - symbol: 'BTC', - name: 'BTC', - price: '$50000', - trend: [[Date.now() - 3_600_000, '49000']], - dataSource: 'terminal-global-snapshot-mark', - sourceExpiresAt: expiresAt, - }, - ], - timestamp: Date.now(), - sourceExpiresAt: expiresAt, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - - const first = controller.getCachedMarketDataForActiveProvider(); - first?.[0].trend?.push([Date.now(), '1']); - first?.splice(0); - const second = controller.getCachedMarketDataForActiveProvider(); - - expect(second).toHaveLength(1); - expect(second?.[0].trend).toHaveLength(1); - }); - it('returns null when no cache exists', () => { markControllerAsInitialized(); controller.testSetProviders(new Map([['hyperliquid', mockProvider]])); @@ -3470,7 +2136,7 @@ describe('PerpsController', () => { expect(result).toBeNull(); }); - it('keeps current provider data when another aggregated entry is stale', () => { + it('returns null in aggregated mode when oldest entry exceeds TTL', () => { const mockMYXProvider = createMockHyperLiquidProvider(); markControllerAsInitialized(); controller.testSetProviders( @@ -3493,7 +2159,7 @@ describe('PerpsController', () => { const result = controller.getCachedMarketDataForActiveProvider(); - expect(result).toEqual([expect.objectContaining({ symbol: 'MYX' })]); + expect(result).toBeNull(); }); }); @@ -3512,36 +2178,6 @@ describe('PerpsController', () => { expect(result).toBeNull(); }); - it('returns null when the selected account cannot be resolved', () => { - const ctrl = new TestablePerpsController({ - messenger: createMockMessenger({ - call: jest.fn().mockImplementation((action: string) => { - if (action === 'RemoteFeatureFlagController:getState') { - return { remoteFeatureFlags: {} }; - } - return undefined; - }), - }), - state: getDefaultPerpsControllerState(), - infrastructure: createMockInfrastructure(), - }); - ctrl.testUpdate((state) => { - state.cachedUserDataByProvider['hyperliquid:mainnet'] = { - positions: [createMockPosition()], - orders: [], - accountState: null, - timestamp: Date.now(), - address: mockAddress, - hip3ConfigVersion: 0, - dexes: ['main'], - }; - }); - - const result = ctrl.getCachedUserDataForActiveProvider({ skipTTL: true }); - - expect(result).toBeNull(); - }); - it('returns cached user data for single provider', () => { const mockPosition = createMockPosition({ symbol: 'BTC', size: '1.0' }); markControllerAsInitialized(); @@ -3561,8 +2197,6 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, - hip3ConfigVersion: 0, - dexes: ['main'], }; }); @@ -3600,8 +2234,6 @@ describe('PerpsController', () => { }, timestamp: Date.now(), address: mockAddress, - hip3ConfigVersion: 0, - dexes: ['main'], }; state.cachedUserDataByProvider['myx:mainnet'] = { positions: [myxPosition], @@ -3649,8 +2281,6 @@ describe('PerpsController', () => { accountState: null, timestamp: Date.now() - 999_999_999, // very old address: mockAddress, - hip3ConfigVersion: 0, - dexes: ['main'], }; }); diff --git a/packages/perps-controller/tests/src/constants/eventNames.test.ts b/packages/perps-controller/tests/src/constants/eventNames.test.ts index 3071008123f..e2b4b7bcea5 100644 --- a/packages/perps-controller/tests/src/constants/eventNames.test.ts +++ b/packages/perps-controller/tests/src/constants/eventNames.test.ts @@ -5,12 +5,6 @@ import { import { PerpsAnalyticsEvent } from '../../../src/types/index.js'; describe('PERPS_EVENT_PROPERTY', () => { - describe('PREVIOUS_LEVERAGE', () => { - it('exports PREVIOUS_LEVERAGE as previous_leverage', () => { - expect(PERPS_EVENT_PROPERTY.PREVIOUS_LEVERAGE).toBe('previous_leverage'); - }); - }); - describe('advanced chart analytics property keys', () => { it('exports CHART_LIBRARY key', () => { expect(PERPS_EVENT_PROPERTY.CHART_LIBRARY).toBe('chart_library'); @@ -91,15 +85,10 @@ describe('PERPS_EVENT_PROPERTY', () => { expect(PERPS_EVENT_PROPERTY.SEARCH_QUERY).toBe('search_query'); expect(PERPS_EVENT_PROPERTY.RESULTS_COUNT).toBe('results_count'); expect(PERPS_EVENT_PROPERTY.RESULT_RANK).toBe('result_rank'); - // Search intent — distinct from PERPS_MODE (Lite/Pro UI) expect(PERPS_EVENT_PROPERTY.MODE).toBe('mode'); expect(PERPS_EVENT_PROPERTY.CURRENT_TOKEN).toBe('current_token'); }); - it('exports PERPS_MODE for Lite/Pro interface mode', () => { - expect(PERPS_EVENT_PROPERTY.PERPS_MODE).toBe('perps_mode'); - }); - it('exports sort / filter and time-on-screen keys', () => { expect(PERPS_EVENT_PROPERTY.SORT_FIELD).toBe('sort_field'); expect(PERPS_EVENT_PROPERTY.SORT_DIRECTION).toBe('sort_direction'); diff --git a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts index 8895e86be49..08625431cd4 100644 --- a/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts +++ b/packages/perps-controller/tests/src/providers/AggregatedPerpsProvider.test.ts @@ -99,8 +99,6 @@ const createMockProvider = (providerId: string): jest.Mocked => { // Configuration setLiveDataConfig: jest.fn(), setUserFeeDiscount: jest.fn(), - setUserFeeResolution: jest.fn(), - approveSubscriptionBuilderFee: jest.fn().mockResolvedValue(true), // Lifecycle toggleTestnet: jest @@ -630,36 +628,6 @@ describe('AggregatedPerpsProvider', () => { expect(mockHLProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(1000); }); - - it('preserves the fee source for providers that support full resolutions', () => { - const resolution = { - feeBips: 0, - discountBips: 10000, - source: 'subscription' as const, - subscription: { eligible: true, reason: 'eligible' as const }, - }; - mockMYXProvider.setUserFeeResolution = undefined; - - aggregatedProvider.setUserFeeResolution(resolution); - - expect(mockHLProvider.setUserFeeResolution).toHaveBeenCalledWith( - resolution, - ); - expect(mockMYXProvider.setUserFeeDiscount).toHaveBeenCalledWith(10000); - }); - - it('delegates subscription builder approval to the default provider', async () => { - await expect( - aggregatedProvider.approveSubscriptionBuilderFee(), - ).resolves.toBe(true); - - expect( - mockHLProvider.approveSubscriptionBuilderFee, - ).toHaveBeenCalledTimes(1); - expect( - mockMYXProvider.approveSubscriptionBuilderFee, - ).not.toHaveBeenCalled(); - }); }); describe('Provider Management', () => { diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts index 1dc635db4d8..40a1f1c30b8 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.advanced-orders.test.ts @@ -1684,76 +1684,8 @@ describe('HyperLiquidProvider', () => { reduceOnly: true, }, ]); - // A lone trigger is the position's take profit whether or not it is - // position-bound, so the scalar summary field reports its price. - expect(position?.takeProfitPrice).toBe('60000'); - expect(position?.takeProfitCount).toBe(1); - }); - - it('leaves the summary price unset when two partial take profits share the position', async () => { - const partialTakeProfit = (oid: number, triggerPx: string) => ({ - coin: 'BTC', - side: 'A', - limitPx: triggerPx, - sz: '0.04', - origSz: '0.04', - oid, - timestamp: 1_700_000_000_000, - triggerCondition: `Price above ${triggerPx}`, - isTrigger: true, - triggerPx, - children: [], - isPositionTpsl: false, - reduceOnly: true, - orderType: 'Take Profit Limit', - }); - - mockClientService.getInfoClient.mockReturnValue( - createMockInfoClient({ - clearinghouseState: jest.fn().mockResolvedValue({ - marginSummary: { totalMarginUsed: '500', accountValue: '10500' }, - crossMarginSummary: { - totalMarginUsed: '500', - accountValue: '10500', - }, - withdrawable: '9500', - assetPositions: [ - { - position: { - coin: 'BTC', - szi: '0.1', - entryPx: '50000', - positionValue: '5000', - unrealizedPnl: '100', - marginUsed: '500', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '45000', - maxLeverage: 50, - returnOnEquity: '20', - cumFunding: { - allTime: '10', - sinceOpen: '5', - sinceChange: '2', - }, - }, - type: 'oneWay', - }, - ], - }), - frontendOpenOrders: jest - .fn() - .mockResolvedValue([ - partialTakeProfit(701, '60000'), - partialTakeProfit(702, '62000'), - ]), - }) as unknown as ReturnType, - ); - - const positions = await provider.getPositions({ skipCache: true }); - const position = positions.find((pos) => pos.symbol === 'BTC'); - - // No single price describes two triggers; the count is what a client shows. - expect(position?.takeProfitCount).toBe(2); + // The scalar summary field stays position-bound-only, which is exactly why + // the array exists. expect(position?.takeProfitPrice).toBeUndefined(); }); }); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts index b2894e630a3..8e64296eb4a 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.builder-fees.test.ts @@ -341,8 +341,6 @@ const createTestProvider = ( blocklistMarkets?: string[]; useUnifiedAccount?: boolean; initialAssetMapping?: [string, number][]; - subscriptionBuilderAddressTestnet?: string; - subscriptionBuilderAddressMainnet?: string; } = {}, ): HyperLiquidProvider => new HyperLiquidProvider({ @@ -691,274 +689,6 @@ describe('HyperLiquidProvider', () => { ); }); - it('routes an approved subscription waiver through the dedicated builder', async () => { - // Builder fee already approved: this test is about the fee value on the - // signed payload, not the approval flow. - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee: jest.fn().mockResolvedValue(0.001), - }), - ); - const orderParams: OrderParams = { - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }; - const exchangeClient = mockClientService.getExchangeClient(); - - // Control: with no source undercutting it, the default builder fee is charged. - const baseline = await provider.placeOrder(orderParams); - - expect(baseline.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledWith( - expect.objectContaining({ - builder: { - b: expect.any(String), - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }, - }), - ); - - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - - (exchangeClient.order as jest.Mock).mockClear(); - provider.setUserFeeResolution({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }); - - const waived = await provider.placeOrder(orderParams); - - expect(waived.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledWith( - expect.objectContaining({ - builder: { b: subscriptionBuilder, f: 0 }, - }), - ); - }); - - it('initializes clients before approving the subscription builder', async () => { - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee: jest.fn().mockResolvedValue(0.001), - }), - ); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - - expect(mockClientService.initialize).toHaveBeenCalledTimes(1); - expect(mockClientService.getInfoClient).toHaveBeenCalled(); - }); - - it('does not reuse subscription builder approval after an account switch', async () => { - const accountA = '0x1234567890123456789012345678901234567890'; - const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - const exchangeClient = mockClientService.getExchangeClient(); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee: jest.fn().mockResolvedValue(0.001), - }), - ); - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - await expect(provider.approveSubscriptionBuilderFee()).resolves.toBe( - true, - ); - - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); - (exchangeClient.order as jest.Mock).mockClear(); - provider.setUserFeeResolution({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(result.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledWith( - expect.objectContaining({ - builder: { - b: BUILDER_FEE_CONFIG.MainnetBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }, - }), - ); - }); - - it('keeps subscription approval reads scoped to the initiating account', async () => { - const accountA = '0x1234567890123456789012345678901234567890'; - const accountB = '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd'; - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - let releaseInitialRead: (value: number) => void = () => undefined; - const initialRead = new Promise((resolve) => { - releaseInitialRead = resolve; - }); - let markInitialReadStarted: () => void = () => undefined; - const initialReadStarted = new Promise((resolve) => { - markInitialReadStarted = resolve; - }); - const maxBuilderFee = jest - .fn() - .mockImplementationOnce(() => { - markInitialReadStarted(); - return initialRead; - }) - .mockResolvedValueOnce(0.001); - mockClientService.getInfoClient = jest - .fn() - .mockReturnValue(createMockInfoClient({ maxBuilderFee })); - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountA); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - const approval = provider.approveSubscriptionBuilderFee(); - await initialReadStarted; - mockWalletService.getUserAddressWithDefault.mockResolvedValue(accountB); - releaseInitialRead(0); - - await expect(approval).resolves.toBe(true); - expect(maxBuilderFee).toHaveBeenNthCalledWith(1, { - user: accountA, - builder: subscriptionBuilder, - }); - expect(maxBuilderFee).toHaveBeenNthCalledWith(2, { - user: accountA, - builder: subscriptionBuilder, - }); - }); - - it('fences subscription approval across disconnect and preserves reconnect dedupe', async () => { - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - let releaseOldRead: (value: number) => void = () => undefined; - const oldRead = new Promise((resolve) => { - releaseOldRead = resolve; - }); - let markOldReadStarted: () => void = () => undefined; - const oldReadStarted = new Promise((resolve) => { - markOldReadStarted = resolve; - }); - let releaseNewRead: (value: number) => void = () => undefined; - const newRead = new Promise((resolve) => { - releaseNewRead = resolve; - }); - let markNewReadStarted: () => void = () => undefined; - const newReadStarted = new Promise((resolve) => { - markNewReadStarted = resolve; - }); - const maxBuilderFee = jest - .fn() - .mockImplementationOnce(() => { - markOldReadStarted(); - return oldRead; - }) - .mockImplementationOnce(() => { - markNewReadStarted(); - return newRead; - }) - .mockResolvedValue(0.001); - mockClientService.getInfoClient = jest - .fn() - .mockReturnValue(createMockInfoClient({ maxBuilderFee })); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - - const oldApproval = provider.approveSubscriptionBuilderFee(); - await oldReadStarted; - await provider.disconnect(); - - const newApproval = provider.approveSubscriptionBuilderFee(); - await newReadStarted; - releaseOldRead(0); - - await expect(oldApproval).resolves.toBe(false); - expect( - mockClientService.getExchangeClient().approveBuilderFee, - ).not.toHaveBeenCalled(); - expect(maxBuilderFee).toHaveBeenCalledTimes(2); - - const dedupedApproval = provider.approveSubscriptionBuilderFee(); - await Promise.resolve(); - expect(maxBuilderFee).toHaveBeenCalledTimes(2); - - releaseNewRead(0.001); - await expect( - Promise.all([newApproval, dedupedApproval]), - ).resolves.toStrictEqual([true, true]); - }); - - it('falls back to the standard fee when the subscription builder is not approved', async () => { - const subscriptionBuilder = '0x2222222222222222222222222222222222222222'; - const defaultBuilder = BUILDER_FEE_CONFIG.MainnetBuilder; - const exchangeClient = mockClientService.getExchangeClient(); - const maxBuilderFee = jest.fn().mockResolvedValue(0.001); - mockClientService.getInfoClient = jest.fn().mockReturnValue( - createMockInfoClient({ - maxBuilderFee, - }), - ); - provider = createTestProvider({ - subscriptionBuilderAddressMainnet: subscriptionBuilder, - }); - provider.setUserFeeResolution({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }); - - const result = await provider.placeOrder({ - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }); - - expect(result.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledWith( - expect.objectContaining({ - builder: { - b: defaultBuilder, - f: BUILDER_FEE_CONFIG.MaxFeeTenthsBps, - }, - }), - ); - expect(maxBuilderFee).not.toHaveBeenCalledWith( - expect.objectContaining({ builder: subscriptionBuilder }), - ); - expect(exchangeClient.approveBuilderFee).not.toHaveBeenCalled(); - }); - it('includes builder fee and referral setup in TP/SL updates', async () => { // Mock builder fee not approved to trigger approval call mockClientService.getInfoClient = jest.fn().mockReturnValue({ diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts index 23c7ce4e0c9..1e37c034a40 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.standalone.test.ts @@ -767,360 +767,6 @@ describe('HyperLiquidProvider', () => { ); }); - describe('getUserDataSnapshot', () => { - it('reuses one clearinghouse response for positions and account state', async () => { - const clearinghouseState = { - assetPositions: [ - { - position: { - coin: 'BTC', - szi: '0.5', - entryPx: '45000', - positionValue: '22500', - unrealizedPnl: '500', - marginUsed: '2250', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '40000', - maxLeverage: 50, - returnOnEquity: '22.22', - cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, - }, - type: 'oneWay', - }, - ], - marginSummary: { - totalMarginUsed: '2250', - accountValue: '25000', - }, - withdrawable: '22750', - }; - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue( - clearinghouseState, - ); - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ - { - coin: 'BTC', - oid: 101, - side: 'A', - limitPx: '0', - triggerPx: '55000', - sz: '0', - origSz: '0', - timestamp: Date.now(), - orderType: 'Take Profit Market', - isTrigger: true, - reduceOnly: true, - isPositionTpsl: true, - cloid: undefined, - children: [], - }, - ]); - - const result = await provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 7, - dexes: ['main'], - }, - }); - - expect(mockStandaloneInfoClient.perpDexs).not.toHaveBeenCalled(); - expect( - mockStandaloneInfoClient.clearinghouseState, - ).toHaveBeenCalledTimes(1); - expect( - mockStandaloneInfoClient.frontendOpenOrders, - ).toHaveBeenCalledTimes(1); - expect( - mockStandaloneInfoClient.spotClearinghouseState, - ).toHaveBeenCalledTimes(1); - expect(mockStandaloneInfoClient.userAbstraction).toHaveBeenCalledTimes( - 1, - ); - expect(result.positions).toHaveLength(1); - expect(result.positions[0]).toEqual( - expect.objectContaining({ - takeProfitCount: 1, - stopLossCount: 0, - takeProfitPrice: '55000', - takeProfitOrders: [ - expect.objectContaining({ - orderId: '101', - size: '0.5', - triggerPrice: '55000', - }), - ], - }), - ); - expect(result.orders).toEqual([ - expect.objectContaining({ - orderId: '101', - size: '0.5', - originalSize: '0.5', - }), - ]); - expect(result.accountState.totalBalance).toBe('25000'); - expect(result.identity).toEqual({ - provider: 'hyperliquid', - network: 'mainnet', - address: mockUserAddress, - hip3ConfigVersion: 7, - dexes: ['main'], - }); - }); - - it('reports a lone partial take profit as the position take profit price', async () => { - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ - assetPositions: [ - { - position: { - coin: 'BTC', - szi: '0.5', - entryPx: '45000', - positionValue: '22500', - unrealizedPnl: '500', - marginUsed: '2250', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '40000', - maxLeverage: 50, - returnOnEquity: '22.22', - cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, - }, - type: 'oneWay', - }, - ], - marginSummary: { - totalMarginUsed: '2250', - accountValue: '25000', - }, - withdrawable: '22750', - }); - // A quantity-scoped take profit is placed with 'na' grouping, so it is - // a standalone reduce-only trigger rather than a position-bound one. - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ - { - coin: 'BTC', - oid: 301, - side: 'A', - limitPx: '55000', - triggerPx: '55000', - sz: '0.2', - origSz: '0.2', - timestamp: Date.now(), - orderType: 'Take Profit Limit', - isTrigger: true, - reduceOnly: true, - isPositionTpsl: false, - cloid: undefined, - children: [], - }, - ]); - - const result = await provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - - expect(result.positions[0]).toEqual( - expect.objectContaining({ - takeProfitPrice: '55000', - takeProfitCount: 1, - stopLossCount: 0, - takeProfitOrders: [ - expect.objectContaining({ orderId: '301', isPartial: true }), - ], - stopLossOrders: [], - }), - ); - expect(result.positions[0].stopLossPrice).toBeUndefined(); - }); - - it('ignores child triggers from the inactive TP/SL grouping', async () => { - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ - assetPositions: [ - { - position: { - coin: 'BTC', - szi: '0.5', - entryPx: '45000', - positionValue: '22500', - unrealizedPnl: '500', - marginUsed: '2250', - leverage: { type: 'cross', value: 10 }, - liquidationPx: '40000', - maxLeverage: 50, - returnOnEquity: '22.22', - cumFunding: { allTime: '10', sinceOpen: '5', sinceChange: '2' }, - }, - type: 'oneWay', - }, - ], - marginSummary: { - totalMarginUsed: '2250', - accountValue: '25000', - }, - withdrawable: '22750', - }); - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([ - { - coin: 'BTC', - oid: 201, - side: 'A', - limitPx: '0', - triggerPx: '55000', - sz: '0', - origSz: '0', - timestamp: Date.now(), - orderType: 'Take Profit Market', - isTrigger: true, - reduceOnly: true, - isPositionTpsl: true, - cloid: undefined, - children: [], - }, - { - coin: 'BTC', - oid: 202, - side: 'B', - limitPx: '44000', - triggerPx: '0', - sz: '0.5', - origSz: '0.5', - timestamp: Date.now(), - orderType: 'Limit', - isTrigger: false, - reduceOnly: false, - isPositionTpsl: false, - cloid: undefined, - children: [ - { - coin: 'BTC', - oid: 203, - side: 'A', - limitPx: '0', - triggerPx: '', - sz: '0', - origSz: '0', - timestamp: Date.now(), - orderType: 'Take Profit Market', - isTrigger: true, - reduceOnly: true, - isPositionTpsl: false, - cloid: undefined, - children: [], - }, - ], - }, - ]); - - const result = await provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - - expect(result.positions[0]).toEqual( - expect.objectContaining({ - takeProfitCount: 1, - takeProfitPrice: '55000', - }), - ); - }); - - it('logs privacy-safe timing for each atomic snapshot stage', async () => { - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ - assetPositions: [], - marginSummary: { totalMarginUsed: '0', accountValue: '0' }, - withdrawable: '0', - }); - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); - - await provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - - const timingCalls = ( - mockPlatformDependencies.debugLogger.log as jest.Mock - ).mock.calls.filter(([marker]) => marker === '[PerpsUserSnapshot]'); - const stages = timingCalls.map(([, detail]) => detail.stage); - expect(stages).toHaveLength(5); - expect(stages).toEqual( - expect.arrayContaining([ - 'clearinghouse_state', - 'frontend_open_orders', - 'spot_clearinghouse_state', - 'user_abstraction', - 'complete', - ]), - ); - expect(JSON.stringify(timingCalls)).not.toContain(mockUserAddress); - }); - - it('accepts canonical DEX identity when a DEX sorts before main', async () => { - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ - assetPositions: [], - marginSummary: { totalMarginUsed: '0', accountValue: '0' }, - withdrawable: '0', - }); - mockStandaloneInfoClient.frontendOpenOrders.mockResolvedValue([]); - - const result = await provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main', 'flx'], - }, - }); - - expect(result.identity.dexes).toEqual(['main', 'flx']); - expect( - mockStandaloneInfoClient.clearinghouseState, - ).toHaveBeenCalledTimes(2); - }); - - it('rejects the entire bundle when one required request fails', async () => { - mockStandaloneInfoClient.clearinghouseState.mockResolvedValue({ - assetPositions: [], - marginSummary: { totalMarginUsed: '0', accountValue: '0' }, - withdrawable: '0', - }); - mockStandaloneInfoClient.frontendOpenOrders.mockRejectedValue( - new Error('orders unavailable'), - ); - - const request = provider.getUserDataSnapshot({ - userAddress: mockUserAddress, - identity: { - provider: 'hyperliquid', - network: 'mainnet', - hip3ConfigVersion: 0, - dexes: ['main'], - }, - }); - - await expect(request).rejects.toThrow('orders unavailable'); - }); - }); - describe('getPositions with standalone mode', () => { it('returns positions via standalone client when standalone mode enabled', async () => { // Arrange diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts index 8548c84d97e..3da6cfc1c3c 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.strategy-orders.test.ts @@ -388,6 +388,11 @@ describe('HyperLiquidProvider - strategy order types', () => { mockedCache.isInFlight.mockReturnValue(undefined); mockedCache.setInFlight.mockReturnValue(jest.fn()); + // Initialize mock stream manager instance + mockStreamManagerInstance = { + clearAllChannels: jest.fn(), + }; + // Create mocked service instances using factory functions mockClientService = { initialize: jest.fn(), @@ -781,73 +786,6 @@ describe('HyperLiquidProvider - strategy order types', () => { ).toBeCloseTo(1, 8); }); - it('weights the rungs along the ladder when a skew is supplied', async () => { - const { exchangeClient } = useStrategyClients({ - exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, - }); - - await provider.placeOrder({ - ...baseOrder, - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 3, - scaleSkew: 2, - } as OrderParams); - - const submitted = exchangeClient.order.mock.calls[0][0]; - const sizes = submitted.orders.map((order: { s: string }) => order.s); - // The largest rung is the one at scaleMaxPrice, and the ladder still adds - // up to the size that was validated. - expect(sizes).toStrictEqual(['0.2222', '0.3333', '0.4445']); - expect( - sizes.reduce( - (total: number, size: string) => total + parseFloat(size), - 0, - ), - ).toBeCloseTo(1, 8); - }); - - it('weights the bottom of the ladder for a skew below 1', async () => { - const { exchangeClient } = useStrategyClients({ - exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, - }); - - await provider.placeOrder({ - ...baseOrder, - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 3, - scaleSkew: 0.5, - } as OrderParams); - - const submitted = exchangeClient.order.mock.calls[0][0]; - expect( - submitted.orders.map((order: { s: string }) => order.s), - ).toStrictEqual(['0.4445', '0.3333', '0.2222']); - }); - - it('splits evenly for a skew of exactly 1', async () => { - const { exchangeClient } = useStrategyClients({ - exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, - }); - - await provider.placeOrder({ - ...baseOrder, - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 3, - scaleSkew: 1, - } as OrderParams); - - const submitted = exchangeClient.order.mock.calls[0][0]; - expect( - submitted.orders.map((order: { s: string }) => order.s), - ).toStrictEqual(['0.3334', '0.3333', '0.3333']); - }); - it('rests every rung as a plain GTC limit order', async () => { const { exchangeClient } = useStrategyClients({ exchange: { order: jest.fn().mockResolvedValue(scaleStatuses) }, @@ -1870,78 +1808,6 @@ describe('HyperLiquidProvider - strategy order types', () => { expect(exchangeClient.order).not.toHaveBeenCalled(); }); - // A ladder whose average rung clears the minimum can still carry a rung - // that does not once the skew has weighted it. - it('never reaches the exchange when a skew starves the cheapest rung', async () => { - const { exchangeClient } = useStrategyClients(); - - const result = await provider.placeOrder({ - ...baseOrder, - usdAmount: '100', - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 5, - scaleSkew: 20, - } as OrderParams); - - expect(result.success).toBe(false); - expect(result.error).toBe( - PERPS_ERROR_CODES.ORDER_SCALE_NOTIONAL_TOO_SMALL, - ); - expect(exchangeClient.order).not.toHaveBeenCalled(); - }); - - it('accepts the same ladder without the skew', async () => { - const { exchangeClient } = useStrategyClients({ - exchange: { - order: jest.fn().mockResolvedValue({ - status: 'ok', - response: { - data: { - statuses: [ - { resting: { oid: 11 } }, - { resting: { oid: 22 } }, - { resting: { oid: 33 } }, - { resting: { oid: 44 } }, - { resting: { oid: 55 } }, - ], - }, - }, - }), - }, - }); - - const result = await provider.placeOrder({ - ...baseOrder, - usdAmount: '100', - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 5, - } as OrderParams); - - expect(result.success).toBe(true); - expect(exchangeClient.order).toHaveBeenCalledTimes(1); - }); - - it('never reaches the exchange for an invalid skew', async () => { - const { exchangeClient } = useStrategyClients(); - - const result = await provider.placeOrder({ - ...baseOrder, - orderType: 'scale', - scaleMinPrice: '2000', - scaleMaxPrice: '3000', - scaleNumOrders: 3, - scaleSkew: 0, - } as OrderParams); - - expect(result.success).toBe(false); - expect(result.error).toBe(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); - expect(exchangeClient.order).not.toHaveBeenCalled(); - }); - it('leaves a chase on the ordinary per-order minimum', async () => { useStrategyClients(); diff --git a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts index ebdffdb5227..1b91658f223 100644 --- a/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts +++ b/packages/perps-controller/tests/src/providers/HyperLiquidProvider.trading.test.ts @@ -535,37 +535,6 @@ describe('HyperLiquidProvider', () => { }); }); describe('Trading Operations', () => { - it('brings the SDK clients up before reading asset metadata', async () => { - // Reproduces the cold-start / post-disconnect failure: placeOrder resolves - // asset info (an InfoClient read) before it ensures trading readiness, so - // an order taken while the clients are still down used to fail with - // CLIENT_NOT_INITIALIZED instead of waiting for them. - let clientsUp = false; - const infoClient = mockClientService.getInfoClient(); - mockClientService.initialize.mockImplementation(async () => { - clientsUp = true; - }); - mockClientService.getInfoClient.mockImplementation(() => { - if (!clientsUp) { - throw new Error(PERPS_ERROR_CODES.CLIENT_NOT_INITIALIZED); - } - return infoClient; - }); - - const orderParams: OrderParams = { - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - currentPrice: 50000, - }; - - const result = await provider.placeOrder(orderParams); - - expect(result.success).toBe(true); - expect(mockClientService.initialize).toHaveBeenCalled(); - }); - it('places a market order successfully', async () => { const orderParams: OrderParams = { symbol: 'BTC', diff --git a/packages/perps-controller/tests/src/selectors.test.ts b/packages/perps-controller/tests/src/selectors.test.ts index b1b309013c5..01983cd6555 100644 --- a/packages/perps-controller/tests/src/selectors.test.ts +++ b/packages/perps-controller/tests/src/selectors.test.ts @@ -639,9 +639,6 @@ describe('PerpsController selectors', () => { positionsSideFilter: 'all', positionsSortField: 'positionValue', positionsSortDirection: 'desc', - ordersSideFilter: 'all', - ordersSortField: 'time', - ordersSortDirection: 'desc', }; it('returns the pro-mode layout preferences', () => { @@ -653,9 +650,6 @@ describe('PerpsController selectors', () => { positionsSideFilter: 'long' as const, positionsSortField: 'unrealizedPnl' as const, positionsSortDirection: 'asc' as const, - ordersSideFilter: 'short' as const, - ordersSortField: 'orderValue' as const, - ordersSortDirection: 'asc' as const, }; const state = { proLayoutPreferences, diff --git a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts index fd7e08d906e..8c638fade2d 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidClientService.test.ts @@ -1639,91 +1639,6 @@ describe('HyperLiquidClientService', () => { ).toBeGreaterThan(subscriptionClientCallsBefore); }); - it('reconnect() recreates exchangeClient and isInitialized() returns true', async () => { - const { ExchangeClient, InfoClient } = require('@nktkas/hyperliquid'); - await service.initialize(mockWallet); - - expect(service.isInitialized()).toBe(true); - - const exchangeCallsBefore = (ExchangeClient as jest.Mock).mock.calls - .length; - const infoCallsBefore = (InfoClient as jest.Mock).mock.calls.length; - - await service.reconnect(); - - // ExchangeClient should have been recreated with HTTP transport - expect((ExchangeClient as jest.Mock).mock.calls.length).toBeGreaterThan( - exchangeCallsBefore, - ); - // InfoClient should have additional calls (WS + HTTP fallback) - expect((InfoClient as jest.Mock).mock.calls.length).toBeGreaterThan( - infoCallsBefore, - ); - // isInitialized() must return true after reconnection - expect(service.isInitialized()).toBe(true); - }); - - it('reconnect() skips exchangeClient when wallet was never provided', async () => { - const { ExchangeClient } = require('@nktkas/hyperliquid'); - - // Create a fresh service without calling initialize() — no wallet stored - const freshService = new HyperLiquidClientService(mockDeps); - const exchangeCallsBefore = (ExchangeClient as jest.Mock).mock.calls - .length; - - await freshService.reconnect(); - - // ExchangeClient should NOT be created since no wallet params exist - expect((ExchangeClient as jest.Mock).mock.calls.length).toBe( - exchangeCallsBefore, - ); - // isInitialized() must be false — exchangeClient was never created - expect(freshService.isInitialized()).toBe(false); - }); - - it('reports uninitialized when reconnect readiness fails', async () => { - await service.initialize(mockWallet); - expect(service.isInitialized()).toBe(true); - - // Clients are constructed before the transport reports ready, so a - // rejected ready() must not leave a session that looks usable. - mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); - await service.reconnect(); - - expect(service.isInitialized()).toBe(false); - expect(service.getSubscriptionClient()).toBeUndefined(); - expect(() => service.getInfoClient()).toThrow('CLIENT_NOT_INITIALIZED'); - expect(service.getInfoClient({ useHttp: true })).toBe(mockInfoClientHttp); - expect(service.getExchangeClient()).toBe(mockExchangeClient); - }); - - it('reports uninitialized when reconnect readiness fails after a disconnect', async () => { - await service.initialize(mockWallet); - await service.disconnect(); - - mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); - await service.reconnect(); - - expect(service.isInitialized()).toBe(false); - }); - - it('does not initialize a competing subscription client during retry backoff', async () => { - const { WebSocketTransport } = require('@nktkas/hyperliquid'); - await service.initialize(mockWallet); - - mockWsTransportReady.mockRejectedValueOnce(new Error('ws never opened')); - await service.reconnect(); - const transportCalls = (WebSocketTransport as jest.Mock).mock.calls - .length; - - await service.ensureSubscriptionClient(mockWallet); - - expect((WebSocketTransport as jest.Mock).mock.calls).toHaveLength( - transportCalls, - ); - expect(service.getSubscriptionClient()).toBeUndefined(); - }); - it('performDisconnection resets isReconnecting flag', async () => { const { WebSocketConnectionState, diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts index 588c494df1d..e6e08fcc9ff 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.cache.test.ts @@ -594,9 +594,6 @@ describe('HyperLiquidSubscriptionService', () => { expect.objectContaining({ user: expect.stringMatching(/^0x/) }), expect.any(Function), ); - expect(mockClientService.getInfoClient).toHaveBeenCalledWith({ - useHttp: true, - }); unsubscribe(); }); diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts index 6f024c2d21b..70e27a2b2bd 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.lifecycle.test.ts @@ -480,38 +480,6 @@ describe('HyperLiquidSubscriptionService', () => { jest.useRealTimers(); }); describe('Subscription Lifecycle', () => { - it('uses validated provider DEX discovery without waiting for the timeout', async () => { - const discoverEnabledDexs = jest.fn().mockResolvedValue(['xyz']); - const discoveryService = new HyperLiquidSubscriptionService( - mockClientService, - mockWalletService, - mockDeps, - true, - [], - [], - [], - undefined, - discoverEnabledDexs, - ); - - const unsubscribe = discoveryService.subscribeToPositions({ - callback: jest.fn(), - }); - - await jest.runAllTimersAsync(); - - expect(discoverEnabledDexs).toHaveBeenCalledTimes(1); - expect(mockSubscriptionClient.clearinghouseState).toHaveBeenCalledWith( - { user: '0x123', dex: 'xyz' }, - expect.any(Function), - ); - expect(mockDeps.debugLogger.log).not.toHaveBeenCalledWith( - 'DEX discovery wait timed out, proceeding with main DEX only', - ); - - unsubscribe(); - }); - it('should unsubscribe from position updates successfully', async () => { const mockCallback = jest.fn(); const mockSubscription = { diff --git a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts index e1d3f447891..600f70849e4 100644 --- a/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts +++ b/packages/perps-controller/tests/src/services/HyperLiquidSubscriptionService.market-data.test.ts @@ -809,91 +809,6 @@ describe('HyperLiquidSubscriptionService', () => { unsubscribe(); }); - it('reports a lone partial take profit as the position take profit price', async () => { - const mockCallback = jest.fn(); - - mockSubscriptionClient.clearinghouseState.mockImplementation( - (_params: any, callback: any) => { - setTimeout(() => { - callback({ - dex: _params.dex || '', - clearinghouseState: { - assetPositions: [ - { - position: { szi: '1.0', coin: 'BTC' }, - coin: 'BTC', - }, - ], - marginSummary: { - accountValue: '10000', - totalMarginUsed: '500', - }, - withdrawable: '9500', - }, - }); - }, 0); - return Promise.resolve({ - unsubscribe: jest.fn().mockResolvedValue(undefined), - }); - }, - ); - - // A quantity-scoped take profit is placed with 'na' grouping, so it is a - // standalone reduce-only trigger and never reaches the position-bound scan. - mockSubscriptionClient.openOrders.mockImplementation( - (_params: any, callback: any) => { - setTimeout(() => { - callback({ - dex: _params.dex || '', - orders: [ - { - oid: 321, - coin: 'BTC', - side: 'S', - sz: '0.4', - triggerPx: '55000', - orderType: 'Take Profit Limit', - reduceOnly: true, - isPositionTpsl: false, - limitPx: '55000', - origSz: '0.4', - timestamp: Date.now(), - isTrigger: true, - triggerCondition: '', - children: [], - tif: null, - cloid: null, - }, - ], - }); - }, 5); - return Promise.resolve({ - unsubscribe: jest.fn().mockResolvedValue(undefined), - }); - }, - ); - - const unsubscribe = service.subscribeToPositions({ - callback: mockCallback, - }); - - await jest.runAllTimersAsync(); - - expect(mockCallback).toHaveBeenCalledWith([ - expect.objectContaining({ - symbol: 'BTC', - takeProfitPrice: '55000', - takeProfitCount: 1, - stopLossCount: 0, - takeProfitOrders: [ - expect.objectContaining({ orderId: '321', isPartial: true }), - ], - }), - ]); - - unsubscribe(); - }); - it('should process Stop Loss orders correctly', async () => { const mockCallback = jest.fn(); diff --git a/packages/perps-controller/tests/src/services/MarketDataService.test.ts b/packages/perps-controller/tests/src/services/MarketDataService.test.ts index 1fab3de302c..9f623f2773c 100644 --- a/packages/perps-controller/tests/src/services/MarketDataService.test.ts +++ b/packages/perps-controller/tests/src/services/MarketDataService.test.ts @@ -823,103 +823,6 @@ describe('MarketDataService', () => { expect(result).toEqual(mockFees); }); - it('surfaces subscription eligibility and remainingNotionalUsd on the fee preview', async () => { - const params: FeeCalculationParams = { - orderType: 'market', - symbol: 'BTC', - amount: '1000', - isMaker: false, - }; - const mockFees: FeeCalculationResult = { - feeRate: 0.0015, - feeAmount: 1.5, - protocolFeeRate: 0.00045, - metamaskFeeRate: 0.001, - }; - mockProvider.calculateFees.mockResolvedValue(mockFees); - - const result = await marketDataService.calculateFees({ - provider: mockProvider, - params, - context: { - ...mockContext, - subscriptionFeeWaiver: { - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 2500, - }, - }, - }); - - expect(result).toStrictEqual({ - ...mockFees, - subscription: { - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 2500, - }, - }); - }); - - it('reads the fee preview waiver status without any side effects', async () => { - const params: FeeCalculationParams = { - orderType: 'market', - symbol: 'BTC', - amount: '1000', - isMaker: false, - }; - const mockFees: FeeCalculationResult = { - feeRate: 0.0015, - feeAmount: 1.5, - protocolFeeRate: 0.00045, - metamaskFeeRate: 0.001, - }; - mockProvider.calculateFees.mockResolvedValue(mockFees); - const waiver = { - eligible: true, - reason: 'eligible' as const, - remainingNotionalUsd: 2500, - }; - - const result = await marketDataService.calculateFees({ - provider: mockProvider, - params, - context: { ...mockContext, subscriptionFeeWaiver: waiver }, - }); - - // The quoted rates are untouched, the cap is not mutated, and the - // provider is asked exactly once for the same params. - expect(result.feeRate).toBe(mockFees.feeRate); - expect(result.metamaskFeeRate).toBe(mockFees.metamaskFeeRate); - expect(result.protocolFeeRate).toBe(mockFees.protocolFeeRate); - expect(waiver).toStrictEqual({ - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 2500, - }); - expect(mockProvider.calculateFees).toHaveBeenCalledTimes(1); - expect(mockProvider.calculateFees).toHaveBeenCalledWith(params); - }); - - it('omits the subscription preview when no waiver status is provided', async () => { - const params: FeeCalculationParams = { - orderType: 'market', - symbol: 'BTC', - amount: '1000', - isMaker: false, - }; - const mockFees: FeeCalculationResult = { feeRate: 0.0015 }; - mockProvider.calculateFees.mockResolvedValue(mockFees); - - const result = await marketDataService.calculateFees({ - provider: mockProvider, - params, - context: mockContext, - }); - - expect(result).toStrictEqual(mockFees); - }); - it('handles fee calculation errors', async () => { const params: FeeCalculationParams = { orderType: 'limit', @@ -1257,13 +1160,8 @@ describe('MarketDataService', () => { ]); beforeEach(() => { - mockDeps.terminalApi = { - ...mockDeps.terminalApi, - globalSnapshotUrl: 'https://terminal.test/v2/perpetuals', - }; mockTerminalService = { fetchMarkets: jest.fn(), - fetchGlobalSnapshot: jest.fn(), clearCache: jest.fn(), logError: jest.fn(), }; @@ -1435,231 +1333,6 @@ describe('MarketDataService', () => { }, ]; - const createGlobalSnapshotContext = ({ - enabledDexes = ['main'], - isCurrent = () => true, - isMarketAllowed = () => true, - }: { - enabledDexes?: string[]; - isCurrent?: () => boolean; - isMarketAllowed?: (symbol: string) => boolean; - } = {}): ServiceContext => ({ - ...mockContext, - globalSnapshot: { - request: { - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes, - }, - isCurrent, - isMarketAllowed, - }, - }); - - it('adopts a configured atomic snapshot independently of the legacy flag', async () => { - const snapshotMarkets: PerpsMarketData[] = [ - { - symbol: 'BTC', - name: 'Bitcoin', - maxLeverage: '50x', - price: '$50001.00', - change24h: '+$125.00', - change24hPercent: '0.25%', - volume: '$1000000', - }, - ]; - mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ - markets: snapshotMarkets, - expiresAt: Date.now() + 30_000, - }); - const isCurrent = jest.fn(() => true); - - const result = await serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - params: { useTerminalApi: false }, - context: createGlobalSnapshotContext({ isCurrent }), - }); - - expect(result).toStrictEqual(snapshotMarkets); - expect(mockTerminalService.fetchGlobalSnapshot).toHaveBeenCalledTimes( - 1, - ); - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - expect(isCurrent).toHaveBeenCalledTimes(2); - }); - - it('falls back when a snapshot expires while being fetched', async () => { - mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ - markets: providerMarketData, - expiresAt: Date.now() - 1, - }); - mockProvider.getMarketDataWithPrices.mockResolvedValue( - providerMarketData, - ); - - const result = await serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - context: createGlobalSnapshotContext(), - }); - - expect(result).toStrictEqual(providerMarketData); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - expect(mockTerminalService.logError).toHaveBeenCalledWith( - expect.objectContaining({ - message: 'Terminal global snapshot expired', - }), - 'getMarketDataWithPrices.globalSnapshot', - ); - }); - - it.each([ - ['timeout', new Error('snapshot timeout')], - ['malformed', new Error('snapshot malformed')], - ['stale', new Error('snapshot stale')], - ['context mismatch', new Error('snapshot identity mismatch')], - ])( - 'falls back to the provider exactly once on %s', - async (_name, error) => { - mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue(error); - mockProvider.getMarketDataWithPrices.mockResolvedValue( - providerMarketData, - ); - - const result = await serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - params: { useTerminalApi: true }, - context: createGlobalSnapshotContext(), - }); - - expect(result).toStrictEqual(providerMarketData); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); - }, - ); - - it('rejects a snapshot context race without calling the captured provider', async () => { - mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ - markets: providerMarketData, - expiresAt: Date.now() + 30_000, - }); - mockProvider.getMarketDataWithPrices.mockResolvedValue( - providerMarketData, - ); - const isCurrent = jest - .fn() - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - - await expect( - serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - context: createGlobalSnapshotContext({ isCurrent }), - }), - ).rejects.toThrow('snapshot context changed'); - - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - expect(mockTerminalService.fetchMarkets).not.toHaveBeenCalled(); - }); - - it('rejects when a failed snapshot fetch also races with a context change', async () => { - mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( - new Error('snapshot network failure'), - ); - const isCurrent = jest - .fn() - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - - await expect( - serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - context: createGlobalSnapshotContext({ isCurrent }), - }), - ).rejects.toThrow('snapshot context changed'); - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - expect(mockTerminalService.logError).not.toHaveBeenCalled(); - }); - - it('applies the existing symbol and list filters before adopting a snapshot', async () => { - const snapshotMarkets = [ - providerMarketData[0] as PerpsMarketData, - { - ...(providerMarketData[1] as PerpsMarketData), - symbol: 'xyz:TSLA', - marketSource: 'xyz', - marketType: 'stock' as const, - isHip3: true, - }, - ]; - mockTerminalService.fetchGlobalSnapshot?.mockResolvedValue({ - markets: snapshotMarkets, - expiresAt: Date.now() + 30_000, - }); - - const result = await serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - params: { - useTerminalApi: true, - categories: ['all'], - excludeSymbols: ['ETH'], - limit: 1, - }, - context: createGlobalSnapshotContext({ - enabledDexes: ['main', 'xyz'], - isMarketAllowed: (symbol) => symbol === 'BTC', - }), - }); - - expect(result).toStrictEqual([providerMarketData[0]]); - expect(mockProvider.getMarketDataWithPrices).not.toHaveBeenCalled(); - }); - - it('propagates provider failure without retry after snapshot rejection', async () => { - mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( - new Error('snapshot rejected'), - ); - mockProvider.getMarketDataWithPrices.mockRejectedValue( - new Error('provider failed'), - ); - - await expect( - serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - params: { useTerminalApi: true }, - context: createGlobalSnapshotContext(), - }), - ).rejects.toThrow('provider failed'); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - }); - - it('rejects a provider fallback result when snapshot context changes during provider await', async () => { - mockTerminalService.fetchGlobalSnapshot?.mockRejectedValue( - new Error('snapshot rejected'), - ); - let resolveProvider: ((markets: PerpsMarketData[]) => void) | undefined; - mockProvider.getMarketDataWithPrices.mockImplementation( - () => - new Promise((resolve) => { - resolveProvider = resolve; - }), - ); - const isCurrent = jest - .fn() - .mockReturnValueOnce(true) - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - - const pending = serviceWithTerminal.getMarketDataWithPrices({ - provider: mockProvider, - context: createGlobalSnapshotContext({ isCurrent }), - }); - await Promise.resolve(); - resolveProvider?.(providerMarketData); - - await expect(pending).rejects.toThrow('snapshot context changed'); - expect(mockProvider.getMarketDataWithPrices).toHaveBeenCalledTimes(1); - }); - it('enriches provider data with terminal metadata when flag is enabled', async () => { mockTerminalService.fetchMarkets.mockResolvedValue({ markets: terminalMarkets, diff --git a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts index 3e9ae3b1f17..1b6a05a0e09 100644 --- a/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts +++ b/packages/perps-controller/tests/src/services/RewardsIntegrationService.test.ts @@ -244,479 +244,6 @@ describe('RewardsIntegrationService', () => { }); }); - describe('unified fee resolver', () => { - // 10 bips = BUILDER_FEE_CONFIG.MaxFeeDecimal (0.001) * BASIS_POINTS_DIVISOR - const DEFAULT_FEE_BIPS = 10; - const FRESH_MS = 60_000; - const MAX_STALE_MS = 10 * 60 * 1000; - const NOW = 1_700_000_000_000; - - /** - * Build a benefits payload that passes the eligibility gate by default. - * - * @param waiverOverrides - Fields to override on `perpsFeeWaiver`. - * @param overrides - Fields to override on the benefits payload itself. - * @returns A benefits payload. - */ - const createBenefits = ( - waiverOverrides: Record = {}, - overrides: Record = {}, - ) => - ({ - status: 'active', - perpsFeeWaiver: { - entitled: true, - usage: 'available', - remainingNotionalUsd: 5000, - ...waiverOverrides, - }, - ...overrides, - }) as never; - - /** - * Wire a subscription benefits source onto the mocked dependencies. - * - * @param getPerpsBenefits - The mocked benefits reader. - * @returns The same mock, for convenience. - */ - const wireSubscription = (getPerpsBenefits: jest.Mock) => { - (mockDeps as { subscription?: unknown }).subscription = { - getPerpsBenefits, - }; - return getPerpsBenefits; - }; - - beforeEach(() => { - jest.useFakeTimers(); - jest.setSystemTime(NOW); - setupMessengerDefaults(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('returns the lowest fee bips across the default, rewards and subscription sources', async () => { - // Rewards unresolved and no subscription source: nothing beats the - // default fee, and the discount stays undefined (not "no discount"). - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(null); - expect(await service.resolveFee()).toMatchObject({ - feeBips: DEFAULT_FEE_BIPS, - discountBips: undefined, - source: 'default', - }); - - // A resolved 0% rewards discount still wins the tie over `default`, so a - // known "no discount" answer stays distinguishable from an unknown one. - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(0); - expect(await service.resolveFee()).toMatchObject({ - feeBips: DEFAULT_FEE_BIPS, - discountBips: 0, - source: 'rewards', - }); - - // A 65% VIP/season discount undercuts the default fee. - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(6500); - expect(await service.resolveFee()).toMatchObject({ - feeBips: 3.5, - discountBips: 6500, - source: 'rewards', - }); - - // Subscription undercuts everything once the cached gate passes. - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - expect(await service.resolveFee()).toMatchObject({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - }); - - // ...including when the rewards source has not hydrated at all. - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(null); - expect(await service.resolveFee()).toMatchObject({ - feeBips: 0, - discountBips: 10000, - source: 'subscription', - }); - }); - - it('resolves the subscription source to a 0 bips fee only when the eligibility gate passes', async () => { - const cases = [ - { benefits: createBenefits(), eligible: true, reason: 'eligible' }, - { - benefits: createBenefits({}, { status: 'canceled' }), - eligible: false, - reason: 'inactive', - }, - { - benefits: createBenefits({ entitled: false }), - eligible: false, - reason: 'not-entitled', - }, - { - benefits: createBenefits({ usage: undefined }), - eligible: false, - reason: 'not-entitled', - }, - { - benefits: createBenefits({ usage: 'exhausted' }), - eligible: false, - reason: 'exhausted', - }, - { - benefits: createBenefits({ exhausted: true }), - eligible: false, - reason: 'exhausted', - }, - // `null` is "no subscription to report", not "subscription inactive". - { benefits: null, eligible: false, reason: 'no-subscription' }, - ]; - - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(0); - - for (const testCase of cases) { - mockDeps = createMockInfrastructure(); - mockMessenger = createMockMessenger(); - setupMessengerDefaults(); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(0); - wireSubscription(jest.fn().mockResolvedValue(testCase.benefits)); - service = new RewardsIntegrationService(mockDeps, mockMessenger); - await service.refreshSubscriptionBenefits(); - - const resolution = await service.resolveFee(); - - expect(resolution.subscription).toStrictEqual( - expect.objectContaining({ - eligible: testCase.eligible, - reason: testCase.reason, - }), - ); - expect(resolution.source).toBe( - testCase.eligible ? 'subscription' : 'rewards', - ); - expect(resolution.feeBips).toBe( - testCase.eligible ? 0 : DEFAULT_FEE_BIPS, - ); - } - }); - - it('does not start a benefits network read on the fee resolution path', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(2500); - - const resolution = await service.resolveFee(); - - expect(resolution.source).toBe('rewards'); - expect(resolution.discountBips).toBe(2500); - expect(resolution.subscription).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - expect(getPerpsBenefits).not.toHaveBeenCalled(); - }); - - it('serves a stale snapshot without refreshing on the cache-read path', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Inside the freshness window: served from cache, no revalidation. - jest.setSystemTime(NOW + FRESH_MS - 1); - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 5000, - }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Past it: the stale snapshot is still served without a request. - jest.setSystemTime(NOW + FRESH_MS + 1); - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 5000, - }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Preview/lifecycle hydration owns the refresh explicitly. - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); - }); - - it('falls back to the next-lowest source when the cached benefits snapshot is hard-stale', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(6500); - await service.refreshSubscriptionBenefits(); - - // Beyond the ceiling the snapshot can no longer be trusted to grant the - // waiver, even though it says the cap is available. - jest.setSystemTime(NOW + MAX_STALE_MS + 1); - getPerpsBenefits.mockImplementation( - async () => new Promise(() => undefined), - ); - - const resolution = await service.resolveFee(); - - expect(resolution.subscription).toStrictEqual({ - eligible: false, - reason: 'stale', - }); - expect(resolution.source).toBe('rewards'); - expect(resolution.feeBips).toBe(3.5); - }); - - it('falls back to the next-lowest source when the benefits read is unreachable', async () => { - wireSubscription( - jest.fn().mockRejectedValue(new Error('benefits endpoint unreachable')), - ); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(6500); - - // The refresh swallows the failure rather than rejecting into callers. - await expect( - service.refreshSubscriptionBenefits(), - ).resolves.toBeUndefined(); - - const resolution = await service.resolveFee(); - - expect(resolution.subscription).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - expect(resolution.source).toBe('rewards'); - expect(resolution.discountBips).toBe(6500); - expect(mockDeps.logger.error).toHaveBeenCalled(); - }); - - it('honors exhausted=true from the backend on the next cache refresh', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(0); - await service.refreshSubscriptionBenefits(); - expect(await service.resolveFee()).toMatchObject({ - source: 'subscription', - feeBips: 0, - }); - - // The backend crosses the cap. No client-side release is needed: the - // next refresh simply stops passing the gate. - getPerpsBenefits.mockResolvedValue( - createBenefits({ exhausted: true, remainingNotionalUsd: 0 }), - ); - jest.setSystemTime(NOW + FRESH_MS + 1); - await service.refreshSubscriptionBenefits(); - - const resolution = await service.resolveFee(); - - expect(resolution.subscription).toStrictEqual({ - eligible: false, - reason: 'exhausted', - remainingNotionalUsd: 0, - }); - expect(resolution.source).toBe('rewards'); - expect(resolution.feeBips).toBe(DEFAULT_FEE_BIPS); - expect(resolution.discountBips).toBe(0); - }); - - it('reports no subscription source when the dependency is not wired', async () => { - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(0); - - const resolution = await service.resolveFee(); - - expect(resolution.subscription).toStrictEqual({ - eligible: false, - reason: 'no-source', - }); - expect(resolution.source).toBe('rewards'); - }); - - it('deduplicates concurrent benefits refreshes', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - - await Promise.all([ - service.refreshSubscriptionBenefits(), - service.refreshSubscriptionBenefits(), - service.refreshSubscriptionBenefits(), - ]); - - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - }); - - it('keeps pure cache reads off the network after a failed refresh', async () => { - // A failing read never advances the snapshot timestamp, so without an - // attempt-based throttle every caller would start a new request. - const getPerpsBenefits = wireSubscription( - jest.fn().mockRejectedValue(new Error('benefits endpoint down')), - ); - - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Ten fee previews inside the freshness window: still one request. - for (let i = 0; i < 10; i++) { - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - } - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Past the window, cache reads still cannot retry on their own. - jest.setSystemTime(NOW + FRESH_MS + 1); - service.getSubscriptionFeeWaiverStatus(); - service.getSubscriptionFeeWaiverStatus(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); - }); - - it('invalidates the cached benefits snapshot on demand', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - await service.refreshSubscriptionBenefits(); - expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); - - // Sign-out / profile switch: the snapshot must stop answering for the - // previous profile immediately, not at the next freshness boundary. - service.invalidateSubscriptionBenefits(); - - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - await service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); - }); - - it('discards an in-flight benefits read that resolves after invalidation', async () => { - // Profile A's read is still in flight when the client signs out. Without - // an epoch fence it would repopulate the cache — and mark it fresh — - // granting profile A's waiver to profile B. - let releaseProfileA: (value: unknown) => void = () => undefined; - const getPerpsBenefits = wireSubscription( - jest.fn( - async () => - new Promise((resolve) => { - releaseProfileA = resolve; - }), - ), - ); - - const inFlight = service.refreshSubscriptionBenefits(); - service.invalidateSubscriptionBenefits(); - releaseProfileA(createBenefits()); - await inFlight; - - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - }); - - it('starts a fresh read when the next caller arrives while a fenced read is still in flight', async () => { - // Profile A's read is still in flight at sign-out, so the epoch fence can - // only discard it. Deduping profile B onto it would leave the cache - // unhydrated instead of fetching for the new identity. - const releases: ((value: unknown) => void)[] = []; - const getPerpsBenefits = wireSubscription( - jest.fn( - async () => - new Promise((resolve) => { - releases.push(resolve); - }), - ), - ); - - const profileARead = service.refreshSubscriptionBenefits(); - service.invalidateSubscriptionBenefits(); - - // Status remains a pure read after invalidation. - expect(service.getSubscriptionFeeWaiverStatus()).toStrictEqual({ - eligible: false, - reason: 'not-hydrated', - }); - expect(getPerpsBenefits).toHaveBeenCalledTimes(1); - - // Preview/lifecycle hydration starts profile B's independent read. - const profileBRead = service.refreshSubscriptionBenefits(); - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); - releases.forEach((release) => release(createBenefits())); - await Promise.all([profileARead, profileBRead]); - - expect(getPerpsBenefits).toHaveBeenCalledTimes(2); - expect(service.getSubscriptionFeeWaiverStatus().eligible).toBe(true); - }); - - it('uses a background refresh that lands during the rewards round trip', async () => { - const getPerpsBenefits = wireSubscription( - jest.fn().mockResolvedValue(createBenefits()), - ); - // The rewards read resolves only after the benefits refresh has landed, - // which is exactly the window a pre-await snapshot would miss. - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockImplementation(async () => { - await service.refreshSubscriptionBenefits(); - return 6500; - }); - - const resolution = await service.resolveFee(); - - expect(getPerpsBenefits).toHaveBeenCalled(); - expect(resolution.subscription.eligible).toBe(true); - expect(resolution.source).toBe('subscription'); - expect(resolution.feeBips).toBe(0); - }); - - it('keeps calculateUserFeeDiscount returning the resolved discount bips', async () => { - wireSubscription(jest.fn().mockResolvedValue(createBenefits())); - ( - mockDeps.rewards.getPerpsDiscountForAccount as jest.Mock - ).mockResolvedValue(6500); - await service.refreshSubscriptionBenefits(); - - expect(await service.calculateUserFeeDiscount()).toBe(10000); - }); - }); - describe('instance isolation', () => { it('each instance uses its own deps', async () => { const mockDeps2 = createMockInfrastructure(); @@ -748,10 +275,9 @@ describe('RewardsIntegrationService', () => { ); await service2.calculateUserFeeDiscount(); - // Each instance should use its own logger: one "no account" log plus the - // resolver's outcome log. - expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(2); - expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(2); + // Each instance should use its own logger + expect(mockDeps.debugLogger.log).toHaveBeenCalledTimes(1); + expect(mockDeps2.debugLogger.log).toHaveBeenCalledTimes(1); }); }); }); diff --git a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts index a0341d04a24..d54ec455e6d 100644 --- a/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts +++ b/packages/perps-controller/tests/src/services/TerminalMarketService.test.ts @@ -3,67 +3,6 @@ import { TerminalMarketService } from '../../../src/services/TerminalMarketServi import type { PerpsPlatformDependencies } from '../../../src/types/index.js'; import { createMockInfrastructure } from '../../helpers/serviceMocks.js'; -const SNAPSHOT_NOW = 1_700_000_030_000; -const HUGE_FINITE_DECIMAL = `1${'0'.repeat(308)}`; - -const createSnapshotMarket = ( - overrides: Record = {}, -): Record => ({ - symbol: 'BTC', - provider: 'hyperliquid', - dex: 'main', - name: 'Bitcoin', - description: 'Original cryptocurrency', - iconUrl: 'https://example.com/btc.png', - szDecimals: 5, - maxLeverage: 50, - markPrice: '50000', - price: '50000', - midPrice: '50001', - oraclePrice: '49999', - change24h: '125', - changePercent24h: 0.25, - funding: '0.0001', - volume24h: '1000000', - openInterest: '1000000', - category: 'crypto', - keywords: ['bitcoin'], - tags: ['top-10'], - listedAt: 1_600_000_000_000, - trend: [ - [SNAPSHOT_NOW - 3_600_000, '49000'], - [SNAPSHOT_NOW - 1_000, '50000'], - ], - ...overrides, -}); - -const createGlobalSnapshot = ( - overrides: Record = {}, -): Record => ({ - schemaVersion: 2, - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - fingerprint: - 'sha256:21c2aec213ce0cf6c0d8624570abfe1a07dd68f7ee2f4e07e9fe2785d3d0212c', - generatedAt: SNAPSHOT_NOW - 1_000, - receivedAt: SNAPSHOT_NOW - 2_000, - maxAgeMs: 60_000, - complete: true, - perDexErrors: [], - markets: [createSnapshotMarket()], - ...overrides, -}); - -const okJsonResponse = (body: unknown): Response => - ({ - ok: true, - status: 200, - statusText: 'OK', - json: () => Promise.resolve(body), - text: () => Promise.resolve(JSON.stringify(body)), - }) as Response; - describe('TerminalMarketService', () => { let mockDeps: jest.Mocked; let service: TerminalMarketService; @@ -167,10 +106,9 @@ describe('TerminalMarketService', () => { }); }); - it('uses the full marketDataUrl without path concatenation', async () => { - mockDeps.terminalApi = { - marketDataUrl: 'https://terminal.api.cx.metamask.io/v1/perpetuals', - }; + it('uses the full terminalApiUrl without path concatenation', async () => { + (mockDeps as Record).terminalApiUrl = + 'https://terminal.api.cx.metamask.io/v1/perpetuals'; jest.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true, @@ -525,527 +463,6 @@ describe('TerminalMarketService', () => { }); }); - describe('fetchGlobalSnapshot', () => { - beforeEach(() => { - jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW); - mockDeps.terminalApi = { - ...mockDeps.terminalApi, - globalSnapshotUrl: - 'https://terminal.test-api.cx.metamask.io/v2/perpetuals', - }; - }); - - it('strictly validates and maps a fresh v2 snapshot', async () => { - jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); - - const result = await service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }); - - expect(result).toStrictEqual({ - markets: [ - { - symbol: 'BTC', - name: 'Bitcoin', - description: 'Original cryptocurrency', - maxLeverage: '50x', - price: '$50000.00', - change24h: '+$125.00', - change24hPercent: '0.25%', - volume: '$1000000', - openInterest: '$1000000', - fundingRate: 0.0001, - marketSource: undefined, - marketType: 'crypto', - isHip3: false, - isNewMarket: false, - keywords: ['bitcoin'], - tags: ['top-10'], - categories: ['crypto'], - listedAt: 1_600_000_000_000, - trend: [ - [SNAPSHOT_NOW - 3_600_000, '49000'], - [SNAPSHOT_NOW - 1_000, '50000'], - ], - dataSource: 'terminal-global-snapshot-mark', - sourceExpiresAt: SNAPSHOT_NOW + 28_000, - }, - ], - expiresAt: SNAPSHOT_NOW + 28_000, - }); - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main', - expect.objectContaining({ method: 'GET' }), - ); - }); - - it('keeps main first when canonicalizing requested DEXes', async () => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: false, - status: 503, - statusText: 'Service Unavailable', - } as Response); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['flx', 'main'], - }), - ).rejects.toThrow('Terminal global snapshot returned 503'); - - expect(globalThis.fetch).toHaveBeenCalledWith( - 'https://terminal.test-api.cx.metamask.io/v2/perpetuals?provider=hyperliquid&network=mainnet&dexes=main%2Cflx', - expect.objectContaining({ method: 'GET' }), - ); - }); - - it.each([ - ['unknown top-level key', createGlobalSnapshot({ extra: true })], - [ - 'unknown market key', - createGlobalSnapshot({ - markets: [createSnapshotMarket({ extra: true })], - }), - ], - [ - 'incoherent mark-based percent', - createGlobalSnapshot({ - markets: [createSnapshotMarket({ changePercent24h: 9 })], - }), - ], - [ - 'incoherent deprecated price alias', - createGlobalSnapshot({ - markets: [createSnapshotMarket({ price: '50001' })], - }), - ], - [ - 'overflowing mark/change subtraction', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - markPrice: HUGE_FINITE_DECIMAL, - change24h: `-${HUGE_FINITE_DECIMAL}`, - changePercent24h: 0, - }), - ], - }), - ], - [ - 'unordered trend timestamps', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - trend: [ - [SNAPSHOT_NOW - 1_000, '50000'], - [SNAPSHOT_NOW - 2_000, '49999'], - ], - }), - ], - }), - ], - [ - 'future trend timestamp', - createGlobalSnapshot({ - markets: [ - createSnapshotMarket({ - trend: [[SNAPSHOT_NOW + 1, '50000']], - }), - ], - }), - ], - [ - 'seconds-based listedAt timestamp', - createGlobalSnapshot({ - markets: [createSnapshotMarket({ listedAt: 1_700_000_000 })], - }), - ], - ])('rejects %s', async (_name, snapshot) => { - jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(okJsonResponse(snapshot)); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }), - ).rejects.toThrow('Terminal global snapshot'); - }); - - it.each([ - ['empty', []], - ['single-point', [[SNAPSHOT_NOW - 1_000, '50000']]], - [ - 'irregular or stale', - [ - [SNAPSHOT_NOW - 10_800_000, '49000'], - [SNAPSHOT_NOW - 1_000, '50000'], - ], - ], - ])('accepts %s optional trend data', async (_name, trend) => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - okJsonResponse( - createGlobalSnapshot({ - markets: [createSnapshotMarket({ trend })], - }), - ), - ); - - const result = await service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }); - - expect(result).toMatchObject({ markets: [{ trend }] }); - }); - - it('rejects a response larger than the snapshot payload limit', async () => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - text: () => Promise.resolve('x'.repeat(1_048_577)), - } as Response); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }), - ).rejects.toThrow('payload exceeds'); - }); - - it('rejects an oversized Content-Length before allocating response text', async () => { - const text = jest.fn().mockRejectedValue(new Error('must not read')); - jest.spyOn(globalThis, 'fetch').mockResolvedValue({ - ok: true, - status: 200, - statusText: 'OK', - headers: { - get: jest.fn().mockReturnValue('1048577'), - } as unknown as Headers, - text, - } as Response); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }), - ).rejects.toThrow('payload exceeds'); - expect(text).not.toHaveBeenCalled(); - }); - - it('aborts when the response body stalls', async () => { - jest.useFakeTimers(); - jest.spyOn(globalThis, 'fetch').mockImplementation((_url, init) => { - const signal = init?.signal as AbortSignal; - return Promise.resolve({ - ok: true, - status: 200, - statusText: 'OK', - headers: { get: () => null } as unknown as Headers, - text: () => - new Promise((_resolve, reject) => { - signal.addEventListener('abort', () => { - reject( - signal.reason instanceof Error - ? signal.reason - : new Error(String(signal.reason)), - ); - }); - }), - } as Response); - }); - - const pending = service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }); - await Promise.resolve(); - - jest.advanceTimersByTime(TERMINAL_API_CONFIG.FetchTimeoutMs); - - await expect(pending).rejects.toThrow( - 'Terminal global snapshot timed out', - ); - jest.useRealTimers(); - }); - - it.each([ - ['version', { schemaVersion: 1 }], - ['provider', { provider: 'other' }], - ['network', { network: 'testnet' }], - ['DEX set', { enabledDexes: ['main', 'xyz'] }], - ['fingerprint', { fingerprint: 'sha256:wrong' }], - ['empty markets', { markets: [] }], - ['incomplete', { complete: false }], - ['per-DEX error', { perDexErrors: [{ dex: 'main', error: 'TIMEOUT' }] }], - ['future generatedAt', { generatedAt: SNAPSHOT_NOW + 5_001 }], - ['future receivedAt', { receivedAt: SNAPSHOT_NOW + 5_001 }], - [ - 'stale source age', - { - generatedAt: SNAPSHOT_NOW - 31_000, - receivedAt: SNAPSHOT_NOW - 31_000, - maxAgeMs: 60_000, - }, - ], - ])('rejects a snapshot with invalid %s', async (_name, overrides) => { - jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(okJsonResponse(createGlobalSnapshot(overrides))); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }), - ).rejects.toThrow('Terminal global snapshot'); - }); - - it('accepts timestamps within the producer clock-skew allowance', async () => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - okJsonResponse( - createGlobalSnapshot({ - generatedAt: SNAPSHOT_NOW + 5_000, - receivedAt: SNAPSHOT_NOW + 5_000, - }), - ), - ); - - const result = await service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }); - - expect(result.markets).toStrictEqual(expect.any(Array)); - }); - - it.each([ - ['oracle price', { oraclePrice: '0' }], - ['mid price', { midPrice: '0' }], - ])('rejects a non-positive %s', async (_name, marketOverrides) => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - okJsonResponse( - createGlobalSnapshot({ - markets: [createSnapshotMarket(marketOverrides)], - }), - ), - ); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: ['main'], - }), - ).rejects.toThrow('reference price'); - }); - - it.each([ - ['duplicate market', [createSnapshotMarket(), createSnapshotMarket()]], - [ - 'missing requested DEX', - [ - createSnapshotMarket(), - createSnapshotMarket({ - symbol: 'BTC2', - }), - ], - ], - ['invalid open interest', [createSnapshotMarket({ openInterest: '-1' })]], - ['empty non-null name', [createSnapshotMarket({ name: '' })]], - ])('rejects %s', async (_name, markets) => { - const needsXyz = _name === 'missing requested DEX'; - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - okJsonResponse( - createGlobalSnapshot({ - ...(needsXyz && { - enabledDexes: ['main', 'xyz'], - fingerprint: - 'sha256:2680c000d74e6b46aaddfc5f944442d235961fcdf1d9063af15989285be39bb7', - }), - markets, - }), - ), - ); - - await expect( - service.fetchGlobalSnapshot({ - provider: 'hyperliquid', - network: 'mainnet', - enabledDexes: needsXyz ? ['main', 'xyz'] : ['main'], - }), - ).rejects.toThrow('Terminal global snapshot'); - }); - - it('coalesces same-key requests and isolates different identities', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) - .mockResolvedValueOnce( - okJsonResponse( - createGlobalSnapshot({ - network: 'testnet', - fingerprint: - 'sha256:0077720707e8b99ea78df074cdaa58522d331b47f7dcd9bd7cff6f706ffd44db', - }), - ), - ); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - await Promise.all([ - service.fetchGlobalSnapshot(request), - service.fetchGlobalSnapshot(request), - ]); - await service.fetchGlobalSnapshot({ - ...request, - network: 'testnet', - }); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('bounds cache TTL by source age and the 30-second consumer cap', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - await service.fetchGlobalSnapshot(request); - jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 27_999); - await service.fetchGlobalSnapshot(request); - expect(fetchSpy).toHaveBeenCalledTimes(1); - - jest.spyOn(Date, 'now').mockReturnValue(SNAPSHOT_NOW + 28_000); - await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( - 'stale', - ); - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('does not cache rejected data', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue( - okJsonResponse(createGlobalSnapshot({ fingerprint: 'invalid' })), - ); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( - 'Terminal global snapshot', - ); - await expect(service.fetchGlobalSnapshot(request)).rejects.toThrow( - 'Terminal global snapshot', - ); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - }); - - it('keeps the legacy cache separate and clears both accepted caches', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())) - .mockResolvedValueOnce(okJsonResponse(mockApiResponse)) - .mockResolvedValueOnce(okJsonResponse(createGlobalSnapshot())); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - await service.fetchGlobalSnapshot(request); - await service.fetchMarkets(); - service.clearCache(); - await service.fetchGlobalSnapshot(request); - - expect(fetchSpy).toHaveBeenCalledTimes(3); - }); - - it('does not reuse or recache an in-flight response after clearCache', async () => { - let resolveFirst: ((response: Response) => void) | undefined; - let resolveSecond: ((response: Response) => void) | undefined; - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveFirst = resolve; - }), - ) - .mockImplementationOnce( - () => - new Promise((resolve) => { - resolveSecond = resolve; - }), - ); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - const oldRequest = service.fetchGlobalSnapshot(request); - service.clearCache(); - const newRequest = service.fetchGlobalSnapshot(request); - resolveFirst?.(okJsonResponse(createGlobalSnapshot())); - await oldRequest; - resolveSecond?.(okJsonResponse(createGlobalSnapshot())); - const fresh = await newRequest; - const cached = await service.fetchGlobalSnapshot(request); - - expect(fetchSpy).toHaveBeenCalledTimes(2); - expect(cached).toStrictEqual(fresh); - expect(cached).not.toBe(fresh); - }); - - it('does not expose mutable references from the validated cache', async () => { - const fetchSpy = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValue(okJsonResponse(createGlobalSnapshot())); - const request = { - provider: 'hyperliquid' as const, - network: 'mainnet' as const, - enabledDexes: ['main'], - }; - - const first = await service.fetchGlobalSnapshot(request); - first.markets[0].trend?.push([SNAPSHOT_NOW, '1']); - first.markets.splice(0); - const second = await service.fetchGlobalSnapshot(request); - - expect(fetchSpy).toHaveBeenCalledTimes(1); - expect(second.markets).toHaveLength(1); - expect(second.markets[0].trend).toHaveLength(2); - }); - }); - describe('cache behavior', () => { it('returns cached data on second call within TTL', async () => { const fetchSpy = jest.spyOn(globalThis, 'fetch').mockResolvedValue({ diff --git a/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts index c8ba09b555f..4ef4129acd3 100644 --- a/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.placeOrder.timeout.test.ts @@ -20,10 +20,7 @@ describe('TradingService.placeOrder — order submission timeout', () => { let tradingService: TradingService; let mockDeps: jest.Mocked; let mockProvider: jest.Mocked; - let mockRewardsService: { - calculateUserFeeDiscount: jest.Mock; - resolveFee: jest.Mock; - }; + let mockRewardsService: { calculateUserFeeDiscount: jest.Mock }; let mockContext: ReturnType; let mockReportOrderToDataLake: jest.Mock; @@ -40,12 +37,6 @@ describe('TradingService.placeOrder — order submission timeout', () => { tradingService = new TradingService(mockDeps); mockRewardsService = { calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), - resolveFee: jest.fn().mockResolvedValue({ - feeBips: 10, - discountBips: undefined, - source: 'default', - subscription: { eligible: false, reason: 'no-source' }, - }), }; tradingService.setControllerDependencies({ rewardsIntegrationService: mockRewardsService as never, @@ -108,6 +99,7 @@ describe('TradingService.placeOrder — order submission timeout', () => { context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, }); + // Advance past the threshold, allowing microtasks (fee discount await) to run first await jest.advanceTimersByTimeAsync( PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, @@ -160,9 +152,6 @@ describe('TradingService.placeOrder — order submission timeout', () => { context: mockContext, reportOrderToDataLake: mockReportOrderToDataLake, }); - const rejection = expect(placeOrderPromise).rejects.toThrow( - 'Provider connection timed out', - ); await jest.advanceTimersByTimeAsync( PERPS_CONSTANTS.PlaceOrderTimeoutMs + 1, @@ -171,7 +160,9 @@ describe('TradingService.placeOrder — order submission timeout', () => { const originalError = new Error('Provider connection timed out'); rejectOrder(originalError); - await rejection; + await expect(placeOrderPromise).rejects.toThrow( + 'Provider connection timed out', + ); const endTraceArgs = (mockDeps.tracer.endTrace as jest.Mock).mock .calls[0][0]; diff --git a/packages/perps-controller/tests/src/services/TradingService.test.ts b/packages/perps-controller/tests/src/services/TradingService.test.ts index cee0872a447..f740265150d 100644 --- a/packages/perps-controller/tests/src/services/TradingService.test.ts +++ b/packages/perps-controller/tests/src/services/TradingService.test.ts @@ -15,7 +15,6 @@ import type { Order, UpdatePositionTPSLParams, PerpsPlatformDependencies, - PerpsFeeResolution, } from '../../../src/types/index.js'; /* eslint-disable */ import { createMockHyperLiquidProvider } from '../../helpers/providerMocks.js'; @@ -37,17 +36,7 @@ describe('TradingService', () => { let mockGetPositions: jest.Mock; let mockGetOpenOrders: jest.Mock; let mockSaveTradeConfiguration: jest.Mock; - let mockRewardsIntegrationService: { - calculateUserFeeDiscount: jest.Mock; - resolveFee: jest.Mock; - }; - - const defaultFeeResolution: PerpsFeeResolution = { - feeBips: 10, - discountBips: undefined, - source: 'default', - subscription: { eligible: false, reason: 'no-source' }, - }; + let mockRewardsIntegrationService: { calculateUserFeeDiscount: jest.Mock }; const createContextWithRewards = (): ServiceContext => createMockServiceContext({ @@ -63,17 +52,6 @@ describe('TradingService', () => { tradingService = new TradingService(mockDeps); mockRewardsIntegrationService = { calculateUserFeeDiscount: jest.fn().mockResolvedValue(undefined), - resolveFee: jest.fn(async () => { - const discountBips = - await mockRewardsIntegrationService.calculateUserFeeDiscount(); - return discountBips === undefined - ? defaultFeeResolution - : { - ...defaultFeeResolution, - discountBips, - source: 'rewards' as const, - }; - }), }; // Set controller dependencies for fee discount calculation tradingService.setControllerDependencies({ @@ -103,117 +81,6 @@ describe('TradingService', () => { }); describe('placeOrder', () => { - it('preserves the subscription source through order construction', async () => { - mockProvider.setUserFeeResolution = jest.fn(); - const subscriptionResolution: PerpsFeeResolution = { - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { - eligible: true, - reason: 'eligible', - remainingNotionalUsd: 1500, - }, - }; - const orderParams: OrderParams = { - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - }; - mockRewardsIntegrationService.resolveFee.mockResolvedValue( - subscriptionResolution, - ); - mockProvider.placeOrder.mockResolvedValue({ success: true }); - - await tradingService.placeOrder({ - provider: mockProvider, - params: orderParams, - context: mockContext, - reportOrderToDataLake: mockReportOrderToDataLake, - }); - - expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( - subscriptionResolution, - ); - expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( - undefined, - ); - }); - - it('isolates fee resolutions between concurrent orders', async () => { - const subscriptionResolution: PerpsFeeResolution = { - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }; - const rewardsResolution: PerpsFeeResolution = { - feeBips: 5, - discountBips: 5000, - source: 'rewards', - subscription: { eligible: false, reason: 'not-entitled' }, - }; - mockRewardsIntegrationService.resolveFee - .mockResolvedValueOnce(subscriptionResolution) - .mockResolvedValueOnce(rewardsResolution); - - let activeResolution: PerpsFeeResolution | undefined; - mockProvider.setUserFeeResolution = jest.fn((resolution) => { - activeResolution = resolution; - }); - let releaseFirst: () => void = () => undefined; - const firstPending = new Promise((resolve) => { - releaseFirst = resolve; - }); - let markFirstStarted: () => void = () => undefined; - const firstStarted = new Promise((resolve) => { - markFirstStarted = resolve; - }); - const observed: Array = []; - mockProvider.placeOrder.mockImplementation(async (params) => { - observed.push(activeResolution); - if (params.symbol === 'BTC') { - markFirstStarted(); - await firstPending; - } - return { success: true }; - }); - - const first = tradingService.placeOrder({ - provider: mockProvider, - params: { - symbol: 'BTC', - isBuy: true, - size: '0.1', - orderType: 'market', - }, - context: mockContext, - reportOrderToDataLake: mockReportOrderToDataLake, - }); - const second = tradingService.placeOrder({ - provider: mockProvider, - params: { - symbol: 'ETH', - isBuy: true, - size: '1', - orderType: 'market', - }, - context: mockContext, - reportOrderToDataLake: mockReportOrderToDataLake, - }); - - await firstStarted; - expect(mockProvider.placeOrder).toHaveBeenCalledTimes(1); - releaseFirst(); - await Promise.all([first, second]); - - expect(observed).toStrictEqual([ - subscriptionResolution, - rewardsResolution, - ]); - }); - it('places order successfully without fee discount', async () => { const orderParams: OrderParams = { symbol: 'BTC', @@ -2463,31 +2330,6 @@ describe('TradingService', () => { stopLossCount: 0, }; - it('preserves the subscription source for flip orders', async () => { - const resolution: PerpsFeeResolution = { - feeBips: 0, - discountBips: 10000, - source: 'subscription', - subscription: { eligible: true, reason: 'eligible' }, - }; - mockProvider.setUserFeeResolution = jest.fn(); - mockRewardsIntegrationService.resolveFee.mockResolvedValue(resolution); - mockProvider.placeOrder.mockResolvedValue({ success: true }); - - await tradingService.flipPosition({ - provider: mockProvider, - position: mockPosition, - context: mockContext, - }); - - expect(mockProvider.setUserFeeResolution).toHaveBeenCalledWith( - resolution, - ); - expect(mockProvider.setUserFeeResolution).toHaveBeenLastCalledWith( - undefined, - ); - }); - it('places order with 2x position size to flip position', async () => { const mockResult: OrderResult = { success: true, diff --git a/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts index a166a72342c..7ec32203a0b 100644 --- a/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts +++ b/packages/perps-controller/tests/src/utils/hyperLiquidValidation.strategy-orders.test.ts @@ -234,57 +234,6 @@ describe('hyperLiquidValidation - strategy order types', () => { error: PERPS_ERROR_CODES.ORDER_SCALE_COUNT_INVALID, }); }); - - it.each([ - ['zero', 0], - ['negative', -1], - ['NaN', NaN], - ['Infinity', Infinity], - ['-Infinity', -Infinity], - ])('rejects a %s skew before anything is signed', (_label, scaleSkew) => { - expect( - validateOrderParams({ - coin: 'ETH', - size: '1', - orderType: 'scale', - ...VALID_STRATEGY_PARAMS.scale, - scaleSkew, - }), - ).toStrictEqual({ - isValid: false, - error: PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID, - }); - }); - - it('accepts an omitted skew', () => { - expect( - validateOrderParams({ - coin: 'ETH', - size: '1', - orderType: 'scale', - ...VALID_STRATEGY_PARAMS.scale, - }), - ).toStrictEqual({ isValid: true }); - }); - - // The client coerces its input to two decimals; nothing here re-rounds it. - it.each([ - ['above 1', 2.35], - ['below 1', 0.25], - ['exactly 1', 1], - ['far above 1', 100], - ['far below 1', 0.01], - ])('accepts a skew %s', (_label, scaleSkew) => { - expect( - validateOrderParams({ - coin: 'ETH', - size: '1', - orderType: 'scale', - ...VALID_STRATEGY_PARAMS.scale, - scaleSkew, - }), - ).toStrictEqual({ isValid: true }); - }); }); describe('validateOrderParams - chase', () => { @@ -341,7 +290,6 @@ describe('hyperLiquidValidation - strategy order types', () => { ['twapRandomize', { twapRandomize: true }], ['scaleMinPrice', { scaleMinPrice: '2000' }], ['scaleNumOrders', { scaleNumOrders: 3 }], - ['scaleSkew', { scaleSkew: 2 }], ['chaseIntervalMs', { chaseIntervalMs: 3000 }], ])('rejects %s on a market order', (_label, strategyField) => { expect( diff --git a/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts index 6442c97770f..2d3b2f0da8c 100644 --- a/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts +++ b/packages/perps-controller/tests/src/utils/orderCalculations.scale-ladder.test.ts @@ -87,130 +87,6 @@ describe('orderCalculations - scale ladder', () => { }); }); - describe('splitScaleSizes - skew', () => { - // The ticket's worked example, taken all the way onto the size grid: total - // 100 over 5 rungs at skew 2 gives weights 1, 1.25, 1.5, 1.75, 2 and ideal - // slices 13.33, 16.67, 20, 23.33, 26.67. The two largest discarded - // fractions are rungs 1 and 4, so they take the two leftover units. - it('ramps the weights linearly and lands the leftover on the largest fractions', () => { - expect( - splitScaleSizes({ - totalSize: 100, - count: 5, - szDecimals: 0, - skew: 2, - }), - ).toStrictEqual(['13', '17', '20', '23', '27']); - }); - - it('weights the top of the ladder when the skew is above 1', () => { - const sizes = splitScaleSizes({ - totalSize: 1, - count: 5, - szDecimals: 2, - skew: 2, - }).map((size) => parseFloat(size)); - - // The ladder runs scaleMinPrice -> scaleMaxPrice, so the last rung is the - // one at scaleMaxPrice. - expect(Math.max(...sizes)).toBe(sizes[sizes.length - 1]); - expect(sizes).toStrictEqual([0.13, 0.17, 0.2, 0.23, 0.27]); - }); - - it('weights the bottom of the ladder when the skew is below 1', () => { - const sizes = splitScaleSizes({ - totalSize: 1, - count: 5, - szDecimals: 2, - skew: 0.5, - }).map((size) => parseFloat(size)); - - expect(Math.max(...sizes)).toBe(sizes[0]); - expect(sizes).toStrictEqual([0.27, 0.23, 0.2, 0.17, 0.13]); - }); - - // A short ladder is still built low price to high price: the skew weights - // the range, not the direction of the trade. - it('does not flip for a sell', () => { - expect( - splitScaleSizes({ totalSize: 100, count: 5, szDecimals: 0, skew: 2 }), - ).toStrictEqual(['13', '17', '20', '23', '27']); - }); - - it('breaks a tie in the discarded fraction by the lower index', () => { - // Weights 1 and 3 over 10 units give 2.5 and 7.5 — one leftover unit and - // two equal fractions. - expect( - splitScaleSizes({ totalSize: 10, count: 2, szDecimals: 0, skew: 3 }), - ).toStrictEqual(['3', '7']); - }); - - it.each([ - ['above 1', 2], - ['below 1', 0.5], - ['far above 1', 100], - ['far below 1', 0.01], - ])( - 'sums to the requested total in grid units with a skew %s', - (_label, skew) => { - const sizes = splitScaleSizes({ - totalSize: 1, - count: 7, - szDecimals: 3, - skew, - }); - - const units = sizes.reduce( - (total, size) => total + Math.round(parseFloat(size) * 1000), - 0, - ); - expect(units).toBe(1000); - }, - ); - - it('still fills every rung under a very high skew', () => { - // Weights 1, 50.5, 100 over 100 units: the first rung's ideal slice is - // 0.66 of a unit, and the leftover unit is what keeps it non-zero. - expect( - splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew: 100 }), - ).toStrictEqual(['0.01', '0.33', '0.66']); - }); - - it('rejects a skew that starves a rung of every unit', () => { - // Same weights, but three units to go round: the first rung's ideal slice - // is 0.02 and there is no leftover left to round it up with. - expect(() => - splitScaleSizes({ - totalSize: 0.03, - count: 3, - szDecimals: 2, - skew: 100, - }), - ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_SIZE_TOO_SMALL); - }); - - it.each([ - ['zero', 0], - ['negative', -2], - ['NaN', NaN], - ['Infinity', Infinity], - ['-Infinity', -Infinity], - ])('rejects %s', (_label, skew) => { - expect(() => - splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew }), - ).toThrow(PERPS_ERROR_CODES.ORDER_SCALE_RANGE_INVALID); - }); - - it('splits exactly as an omitted skew does when the skew is 1', () => { - const even = splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2 }); - - expect(even).toStrictEqual(['0.34', '0.33', '0.33']); - expect( - splitScaleSizes({ totalSize: 1, count: 3, szDecimals: 2, skew: 1 }), - ).toStrictEqual(even); - }); - }); - describe('splitScaleSizes - rung count', () => { // Exported on its own, so it cannot rely on computeScalePriceLadder having // vetted the count first: zero would return an empty split, and a diff --git a/packages/perps-controller/tests/src/utils/orderTypes.test.ts b/packages/perps-controller/tests/src/utils/orderTypes.test.ts index 97b4a585170..c8bdc032b73 100644 --- a/packages/perps-controller/tests/src/utils/orderTypes.test.ts +++ b/packages/perps-controller/tests/src/utils/orderTypes.test.ts @@ -1,4 +1,4 @@ -import type { Order, PositionTriggerOrder } from '../../../src/types/index.js'; +import type { Order } from '../../../src/types/index.js'; import type { OrderType, TriggerOrderType, @@ -13,7 +13,6 @@ import { getTriggerExecution, isLimitExecutionOrderType, isTriggerOrderType, - resolvePositionTriggerSummaryPrice, } from '../../../src/utils/orderTypes.js'; const createOrder = (overrides: Partial = {}): Order => ({ @@ -347,68 +346,4 @@ describe('orderTypes', () => { expect(result?.reduceOnly).toBe(false); }); }); - - describe('resolvePositionTriggerSummaryPrice', () => { - const createTriggerOrder = ( - overrides: Partial = {}, - ): PositionTriggerOrder => ({ - orderId: '901', - direction: 'take_profit', - orderType: 'take_profit_limit', - triggerPrice: '60000', - size: '0.04', - isPartial: true, - reduceOnly: true, - ...overrides, - }); - - it('reports the price of a lone trigger order, partial or not', () => { - expect( - resolvePositionTriggerSummaryPrice({ - triggerOrders: [createTriggerOrder()], - }), - ).toBe('60000'); - }); - - it('prefers the lone trigger order over a differing scanned price', () => { - expect( - resolvePositionTriggerSummaryPrice({ - triggerOrders: [createTriggerOrder({ triggerPrice: '61000' })], - scannedPrice: '60000', - }), - ).toBe('61000'); - }); - - it('keeps the scanned price when several trigger orders share a direction', () => { - expect( - resolvePositionTriggerSummaryPrice({ - triggerOrders: [ - createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), - createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), - ], - scannedPrice: '59000', - }), - ).toBe('59000'); - }); - - it('reports nothing when several trigger orders share a direction and none was scanned', () => { - expect( - resolvePositionTriggerSummaryPrice({ - triggerOrders: [ - createTriggerOrder({ orderId: '901', triggerPrice: '60000' }), - createTriggerOrder({ orderId: '902', triggerPrice: '61000' }), - ], - }), - ).toBeUndefined(); - }); - - it('keeps the scanned price when there is no trigger order, which is how a pending order TP/SL still reports', () => { - expect( - resolvePositionTriggerSummaryPrice({ - triggerOrders: [], - scannedPrice: '60000', - }), - ).toBe('60000'); - }); - }); }); diff --git a/packages/polling-controller/CHANGELOG.md b/packages/polling-controller/CHANGELOG.md index 8027cc91bf4..1b13d6108e0 100644 --- a/packages/polling-controller/CHANGELOG.md +++ b/packages/polling-controller/CHANGELOG.md @@ -7,18 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Export `AbstractPollingControllerBaseMixin`, `getKey`, `Constructor`, and `PollingTokenSetId` ([#9882](https://github.com/MetaMask/core/pull/9882)) - ### Changed - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) -### Removed - -- **BREAKING:** Remove `BlockTrackerPollingController` and `BlockTrackerPollingControllerOnly` ([#9882](https://github.com/MetaMask/core/pull/9882)) - ## [16.0.9] ### Changed diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json index b010ea97ea0..7a7a59aeef1 100644 --- a/packages/polling-controller/package.json +++ b/packages/polling-controller/package.json @@ -54,6 +54,7 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", + "@metamask/network-controller": "^35.0.1", "@metamask/utils": "^11.11.0", "@types/uuid": "^8.3.0", "fast-json-stable-stringify": "^2.1.0", diff --git a/packages/user-operation-controller/src/BlockTrackerPollingController.test.ts b/packages/polling-controller/src/BlockTrackerPollingController.test.ts similarity index 98% rename from packages/user-operation-controller/src/BlockTrackerPollingController.test.ts rename to packages/polling-controller/src/BlockTrackerPollingController.test.ts index b509acf9ad8..b6eb5ea34e8 100644 --- a/packages/user-operation-controller/src/BlockTrackerPollingController.test.ts +++ b/packages/polling-controller/src/BlockTrackerPollingController.test.ts @@ -33,11 +33,11 @@ class ChildBlockTrackerPollingController extends BlockTrackerPollingController ({ - MESSAGE_SIGNING_SNAP_ID: 'npm:@metamask/message-signing-snap', - getMessageSigningPublicKey: jest.fn(async () => 'MOCK_PUBLIC_KEY'), - signMessageWithMessageSigningKey: jest.fn(async () => 'MOCK_SIGNED_MESSAGE'), - deriveMessageSigningPrivateKey: jest.fn(), - deriveSip6PrivateKey: jest.fn(), -})); - -const MOCK_HD_SEED = new Uint8Array(64).fill(1); - const MOCK_ENTROPY_SOURCE_IDS = [ 'MOCK_ENTROPY_SOURCE_ID', 'MOCK_ENTROPY_SOURCE_ID2', @@ -152,7 +138,7 @@ describe('AuthenticationController', () => { it('should create access token(s) and update state', async () => { const metametrics = createMockAuthMetaMetrics(); const mockEndpoints = arrangeAuthAPIs(); - const { messenger, mockGetPublicKey, mockSignMessage } = + const { messenger, mockSnapGetPublicKey, mockSnapSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -161,11 +147,11 @@ describe('AuthenticationController', () => { }); const result = await controller.performSignIn(); - // SRP enumeration uses KeyringController; native SIP-6 is used for + // SRP enumeration uses KeyringController; snap is only needed for // getPublicKey / signMessage during cold login. - expect(mockGetPublicKey).toHaveBeenCalledTimes(2); + expect(mockSnapGetPublicKey).toHaveBeenCalledTimes(2); // Primary and secondary tags produce distinct messages, so both are signed. - expect(mockSignMessage).toHaveBeenCalledTimes(2); + expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -180,10 +166,10 @@ describe('AuthenticationController', () => { } }); - it('leverages the signMessage cache', async () => { + it('leverages the _snapSignMessageCache', async () => { const metametrics = createMockAuthMetaMetrics(); const mockEndpoints = arrangeAuthAPIs(); - const { messenger, mockSignMessage } = + const { messenger, mockSnapSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -195,7 +181,7 @@ describe('AuthenticationController', () => { controller.performSignOut(); await controller.performSignIn(); // Both tagged login messages are cached across sign-out / sign-in. - expect(mockSignMessage).toHaveBeenCalledTimes(2); + expect(mockSnapSignMessage).toHaveBeenCalledTimes(2); mockEndpoints.mockNonceUrl.done(); mockEndpoints.mockSrpLoginUrl.done(); mockEndpoints.mockOAuth2TokenUrl.done(); @@ -208,7 +194,7 @@ describe('AuthenticationController', () => { it('signs primary and secondary login tags for multi-SRP wallets', async () => { const metametrics = createMockAuthMetaMetrics(); arrangeAuthAPIs(); - const { messenger, mockSignMessage } = + const { messenger, mockSnapSignMessage } = createMockAuthenticationMessenger(); const controller = new AuthenticationController({ @@ -218,8 +204,8 @@ describe('AuthenticationController', () => { await controller.performSignIn(); - const signedMessages = mockSignMessage.mock.calls.map( - (call) => call[0] as string, + const signedMessages = mockSnapSignMessage.mock.calls.map( + (call) => (call[0] as { message: string }).message, ); expect(signedMessages).toStrictEqual( expect.arrayContaining([ @@ -234,7 +220,7 @@ describe('AuthenticationController', () => { arrangeAuthAPIs(); const { messenger, - mockSignMessage, + mockSnapSignMessage, mockKeyringControllerGetState, mockSeedlessOnboardingGetState, } = createMockAuthenticationMessenger(); @@ -254,9 +240,10 @@ describe('AuthenticationController', () => { await controller.performSignIn(); - expect(mockSignMessage).toHaveBeenCalledWith( - expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), - MOCK_HD_SEED, + expect(mockSnapSignMessage).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringMatching(/^metamask:[^:]+:[^:]+:primary$/u), + }), ); }); @@ -1065,9 +1052,9 @@ describe('AuthenticationController', () => { expect(resultUndefined).toBe(resultExplicit); }); - it('resolves primary entropySourceId from the HD keyring without signing', async () => { + it('resolves primary entropySourceId from the HD keyring without the snap', async () => { const metametrics = createMockAuthMetaMetrics(); - const { messenger, mockGetPublicKey, mockKeyringControllerGetState } = + const { messenger, mockSnapGetPublicKey, mockKeyringControllerGetState } = createMockAuthenticationMessenger(); const originalState = mockSignedInState(); const controller = new AuthenticationController({ @@ -1080,8 +1067,8 @@ describe('AuthenticationController', () => { await controller.getBearerToken(); await controller.getBearerToken(); - // Cached session: no identify/sign; only keyring for primary ID. - expect(mockGetPublicKey).not.toHaveBeenCalled(); + // Cached session: no snap identify/sign; only keyring for primary ID. + expect(mockSnapGetPublicKey).not.toHaveBeenCalled(); expect(mockKeyringControllerGetState).toHaveBeenCalled(); }); @@ -1662,7 +1649,7 @@ function createAuthenticationMessenger(): { messenger, actions: [ 'KeyringController:getState', - 'KeyringController:withKeyringV2Unsafe', + 'SnapController:handleRequest', 'SeedlessOnboardingController:getState', ], events: ['KeyringController:lock', 'KeyringController:unlock'], @@ -1679,58 +1666,50 @@ function createAuthenticationMessenger(): { function createMockAuthenticationMessenger(): { messenger: AuthenticationControllerMessenger; baseMessenger: RootMessenger; - mockGetPublicKey: jest.Mock; - mockSignMessage: jest.Mock; + mockSnapGetPublicKey: jest.Mock; + mockSnapSignMessage: jest.Mock; mockKeyringControllerGetState: jest.Mock; - mockWithKeyringV2Unsafe: jest.Mock; mockSeedlessOnboardingGetState: jest.Mock; } { const { baseMessenger, messenger } = createAuthenticationMessenger(); const mockCall = jest.spyOn(messenger, 'call'); - const mockGetPublicKey = jest.mocked(getMessageSigningPublicKey); - const mockSignMessage = jest.mocked(signMessageWithMessageSigningKey); - mockGetPublicKey.mockReset().mockResolvedValue('MOCK_PUBLIC_KEY'); - mockSignMessage.mockReset().mockResolvedValue('MOCK_SIGNED_MESSAGE'); + const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); + const mockSnapSignMessage = jest + .fn() + .mockResolvedValue('MOCK_SIGNED_MESSAGE'); const mockKeyringControllerGetState = jest.fn().mockReturnValue({ isUnlocked: true, keyrings: MOCK_HD_KEYRINGS, }); - const mockWithKeyringV2Unsafe = jest - .fn() - .mockImplementation( - async ( - _selector: { id: string }, - operation: (context: { - keyring: { type: string; seed?: Uint8Array }; - metadata: { id: string; name: string }; - }) => Promise, - ) => { - return operation({ - keyring: { type: 'hd', seed: MOCK_HD_SEED }, - metadata: { id: 'mock', name: '' }, - }); - }, - ); - const mockSeedlessOnboardingGetState = jest .fn() .mockReturnValue({ vault: null }); - mockCall.mockImplementation((...args: unknown[]) => { - const [actionType] = args; - if (actionType === 'KeyringController:withKeyringV2Unsafe') { - const [, selector, operation] = args as [ - typeof actionType, - { id: string }, - (context: { - keyring: { type: string; seed?: Uint8Array }; - metadata: { id: string; name: string }; - }) => Promise, - ]; - return mockWithKeyringV2Unsafe(selector, operation); + mockCall.mockImplementation((...args) => { + const [actionType, params] = args; + if (actionType === 'SnapController:handleRequest') { + if (typeof params === 'string') { + throw new Error( + `MOCK_FAIL - unsupported SnapController:handleRequest call: ${params}`, + ); + } + + if (params?.request.method === 'getPublicKey') { + return mockSnapGetPublicKey(); + } + + if (params?.request.method === 'signMessage') { + return mockSnapSignMessage(params.request.params); + } + + throw new Error( + `MOCK_FAIL - unsupported SnapController:handleRequest call: ${ + params?.request.method as string + }`, + ); } if (actionType === 'KeyringController:getState') { @@ -1749,10 +1728,9 @@ function createMockAuthenticationMessenger(): { return { messenger, baseMessenger, - mockGetPublicKey, - mockSignMessage, + mockSnapGetPublicKey, + mockSnapSignMessage, mockKeyringControllerGetState, - mockWithKeyringV2Unsafe, mockSeedlessOnboardingGetState, }; } @@ -1767,7 +1745,13 @@ function createMockAuthenticationMessenger(): { function mockAuthenticationFlowEndpoints(params?: { endpointFail: 'nonce' | 'login' | 'token' | 'lineage' | 'customerService'; }): ReturnType { - return arrangeAuthAPIs({ + const { + mockNonceUrl, + mockOAuth2TokenUrl, + mockSrpLoginUrl, + mockUserProfileLineageUrl, + mockCustomerServiceTokenUrl, + } = arrangeAuthAPIs({ mockNonceUrl: params?.endpointFail === 'nonce' ? { status: 500 } : undefined, mockSrpLoginUrl: @@ -1779,6 +1763,14 @@ function mockAuthenticationFlowEndpoints(params?: { mockCustomerServiceTokenUrl: params?.endpointFail === 'customerService' ? { status: 500 } : undefined, }); + + return { + mockNonceUrl, + mockOAuth2TokenUrl, + mockSrpLoginUrl, + mockUserProfileLineageUrl, + mockCustomerServiceTokenUrl, + }; } /** diff --git a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts index 1a9eb824b5e..c9e3d4d1276 100644 --- a/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts +++ b/packages/profile-sync-controller/src/controllers/authentication/AuthenticationController.ts @@ -8,10 +8,10 @@ import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, KeyringControllerUnlockEvent, - KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { SeedlessOnboardingControllerGetStateAction } from '@metamask/seedless-onboarding-controller'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { Json } from '@metamask/utils'; import type { @@ -34,11 +34,10 @@ import { getHdKeyringEntropySourceIds, getPrimaryHdKeyringEntropySourceId, } from '../../shared/utils/entropy-source.js'; -import { getHdKeyringSeed } from '../../shared/utils/hd-keyring-seed.js'; import { - getMessageSigningPublicKey, - signMessageWithMessageSigningKey, -} from '../../shared/utils/message-signing.js'; + createSnapPublicKeyRequest, + createSnapSignMessageRequest, +} from './auth-snap-requests.js'; import { AuthenticationControllerMethodActions } from './AuthenticationController-method-action-types.js'; const controllerName = 'AuthenticationController'; @@ -156,7 +155,7 @@ export type Events = // Allowed Actions type AllowedActions = | KeyringControllerGetStateAction - | KeyringControllerWithKeyringV2UnsafeAction + | SnapControllerHandleRequestAction | SeedlessOnboardingControllerGetStateAction; type AllowedEvents = KeyringControllerLockEvent | KeyringControllerUnlockEvent; @@ -252,8 +251,8 @@ export class AuthenticationController extends BaseController< setLoginResponse: this.#setLoginResponseToState.bind(this), }, signing: { - getIdentifier: this.#getPublicKey.bind(this), - signMessage: this.#signMessage.bind(this), + getIdentifier: this.#snapGetPublicKey.bind(this), + signMessage: this.#snapSignMessage.bind(this), }, getLoginTag: this.#getLoginTag.bind(this), getLoginIdentifierType: this.#getLoginIdentifierType.bind(this), @@ -681,78 +680,51 @@ export class AuthenticationController extends BaseController< } /** - * Reads the BIP-39 seed for an HD entropy source from KeyringController. - * - * @param entropySourceId - Entropy source ID. Defaults to the primary HD - * keyring. - * @returns The HD keyring seed. - */ - async #getHdKeyringSeed(entropySourceId?: string): Promise { - const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId(); - return getHdKeyringSeed(this.messenger, resolvedId); - } - - /** - * Returns the message-signing public key via native SIP-6 derivation - * (same key as `@metamask/message-signing-snap` with empty salt). + * Returns the auth snap public key. * * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns The public key hex. + * @returns The snap public key. */ - async #getPublicKey(entropySourceId?: string): Promise { - this.#assertIsUnlocked('#getPublicKey'); - const seed = await this.#getHdKeyringSeed(entropySourceId); - return getMessageSigningPublicKey(seed); - } + async #snapGetPublicKey(entropySourceId?: string): Promise { + this.#assertIsUnlocked('#snapGetPublicKey'); - #_signMessageCache: Record = {}; + const result = (await this.messenger.call( + 'SnapController:handleRequest', + createSnapPublicKeyRequest(entropySourceId), + )) as string; - /** - * Builds a cache key scoped to a specific entropy source, so each SRP's - * signature stays isolated (same pattern as `UserStorageController`). - * - * When `entropySourceId` is omitted (primary SRP), it is resolved to the - * primary HD keyring's metadata ID rather than a stable literal. Because that - * ID is randomly regenerated whenever the vault is recreated (e.g. on - * restore), the cached entry is naturally invalidated across vaults — a - * different SRP can never inherit the previous primary's cached signature. - * - * @param message - The tagged message used for signing. - * @param entropySourceId - The entropy source ID. Omit for the primary SRP. - * @returns The scoped cache key. - */ - #scopedCacheKey( - message: `metamask:${string}`, - entropySourceId?: string, - ): string { - return `${entropySourceId ?? this.#getPrimaryEntropySourceId()}:${message}`; + return result; } + #_snapSignMessageCache: Record<`metamask:${string}`, string> = {}; + /** - * Signs a `metamask:…` message with the native SIP-6 message-signing key. + * Signs a specific message using an underlying auth snap. * * @param message - A specific tagged message to sign. * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns Compact secp256k1 signature hex. + * @returns A Signature created by the snap. */ - async #signMessage( + async #snapSignMessage( message: string, entropySourceId?: string, ): Promise { assertMessageStartsWithMetamask(message); - this.#assertIsUnlocked('#signMessage'); - const cacheKey = this.#scopedCacheKey(message, entropySourceId); - if (this.#_signMessageCache[cacheKey]) { - return this.#_signMessageCache[cacheKey]; + if (this.#_snapSignMessageCache[message]) { + return this.#_snapSignMessageCache[message]; } - const seed = await this.#getHdKeyringSeed(entropySourceId); - const result = await signMessageWithMessageSigningKey(message, seed); + this.#assertIsUnlocked('#snapSignMessage'); + + const result = (await this.messenger.call( + 'SnapController:handleRequest', + createSnapSignMessageRequest(message, entropySourceId), + )) as string; - this.#_signMessageCache[cacheKey] = result; + this.#_snapSignMessageCache[message] = result; return result; } diff --git a/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts b/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts new file mode 100644 index 00000000000..325669fa75b --- /dev/null +++ b/packages/profile-sync-controller/src/controllers/authentication/auth-snap-requests.ts @@ -0,0 +1,53 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; +import type { SnapId } from '@metamask/snaps-sdk'; + +type SnapRPCRequest = Parameters< + SnapControllerHandleRequestAction['handler'] +>[0]; + +const snapId = 'npm:@metamask/message-signing-snap' as SnapId; + +/** + * Constructs Request to Message Signing Snap to get Public Key + * + * @param entropySourceId - The source of entropy to use for key generation, + * when multiple sources are available (Multi-SRP). + * @returns Snap Public Key Request + */ +export function createSnapPublicKeyRequest( + entropySourceId?: string, +): SnapRPCRequest { + return { + snapId, + origin: 'metamask', + handler: 'onRpcRequest' as any, + request: { + method: 'getPublicKey', + ...(entropySourceId ? { params: { entropySourceId } } : {}), + }, + }; +} + +/** + * Constructs Request to get Message Signing Snap to sign a message. + * + * @param message - message to sign + * @param entropySourceId - The source of entropy to use for key generation, + * when multiple sources are available (Multi-SRP). + * @returns Snap Sign Message Request + */ +export function createSnapSignMessageRequest( + message: `metamask:${string}`, + entropySourceId?: string, +): SnapRPCRequest { + return { + snapId, + origin: 'metamask', + handler: 'onRpcRequest' as any, + request: { + method: 'signMessage', + params: { message, ...(entropySourceId ? { entropySourceId } : {}) }, + }, + }; +} diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts index d8c163801ac..829c450ef21 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.test.ts @@ -26,14 +26,6 @@ import { defaultState, } from './UserStorageController.js'; -jest.mock('../../shared/utils/message-signing.js', () => ({ - MESSAGE_SIGNING_SNAP_ID: 'npm:@metamask/message-signing-snap', - getMessageSigningPublicKey: jest.fn(async () => 'MOCK_PUBLIC_KEY'), - signMessageWithMessageSigningKey: jest.fn(async () => 'mockStorageKey'), - deriveMessageSigningPrivateKey: jest.fn(), - deriveSip6PrivateKey: jest.fn(), -})); - describe('UserStorageController', () => { describe('constructor', () => { const arrangeMocks = () => { @@ -720,7 +712,7 @@ describe('UserStorageController', () => { }); }); - describe('message signing', () => { + describe('snap handling', () => { it('leverages a cache', async () => { const messengerMocks = mockUserStorageMessenger(); const controller = new UserStorageController({ @@ -737,7 +729,7 @@ describe('UserStorageController', () => { // The signed message (`metamask:${profileId}`) is identical across both // calls, so the only thing that can isolate the two vaults is the entropy // scope. The HD keyring metadata id is randomly regenerated on restore. - messengerMocks.mockSignMessage + messengerMocks.mockSnapSignMessage .mockResolvedValueOnce('signature-before-restore') .mockResolvedValueOnce('signature-after-restore'); @@ -764,7 +756,7 @@ describe('UserStorageController', () => { // The regenerated id changes the cache scope, so the new primary must // re-derive its own key instead of inheriting the previous vault's cached // key — proving no `'primary'`-style stable key carries across restores. - expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(2); + expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(2); expect(keyAfterRestore).not.toBe(keyBeforeRestore); expect(keyAfterRestore).toBe(createSHA256Hash('signature-after-restore')); }); @@ -795,7 +787,7 @@ describe('UserStorageController', () => { mockAPI1.done(); mockAPI2.done(); - expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(1); + expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(1); }); it('derives a distinct storage key per entropy source even when both resolve to the same profileId', async () => { @@ -816,7 +808,7 @@ describe('UserStorageController', () => { }); // Each entropy source signs with its own key, so the identical message // yields a different signature — and thus a different derived storage key. - messengerMocks.mockSignMessage + messengerMocks.mockSnapSignMessage .mockResolvedValueOnce('signature-for-entropy-source-1') .mockResolvedValueOnce('signature-for-entropy-source-2'); @@ -849,7 +841,7 @@ describe('UserStorageController', () => { // storage keys despite the shared profileId. mockSource1.done(); mockSource2.done(); - expect(messengerMocks.mockSignMessage).toHaveBeenCalledTimes(2); + expect(messengerMocks.mockSnapSignMessage).toHaveBeenCalledTimes(2); }); it('throws if the wallet is locked', async () => { @@ -863,7 +855,7 @@ describe('UserStorageController', () => { }); await expect(controller.getStorageKey()).rejects.toThrow( - '#signMessage - unable to proceed, wallet is locked', + '#snapSignMessage - unable to call snap, wallet is locked', ); await expect(controller.listEntropySources()).rejects.toThrow( 'listEntropySources - unable to list entropy sources, wallet is locked', @@ -891,7 +883,7 @@ describe('UserStorageController', () => { messengerMocks.baseMessenger.publish('KeyringController:lock'); await expect(controller.getStorageKey()).rejects.toThrow( - '#signMessage - unable to proceed, wallet is locked', + '#snapSignMessage - unable to call snap, wallet is locked', ); messengerMocks.baseMessenger.publish('KeyringController:unlock'); diff --git a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts index e58d9923d9c..8271ae76469 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/UserStorageController.ts @@ -21,9 +21,9 @@ import type { KeyringControllerGetStateAction, KeyringControllerLockEvent, KeyringControllerUnlockEvent, - KeyringControllerWithKeyringV2UnsafeAction, } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; +import type { SnapControllerHandleRequestAction } from '@metamask/snaps-controllers'; import type { UserStorageGenericFeatureKey, @@ -37,8 +37,7 @@ import { getPrimaryHdKeyringEntropySourceId, } from '../../shared/utils/entropy-source.js'; import { EventQueue } from '../../shared/utils/event-queue.js'; -import { getHdKeyringSeed } from '../../shared/utils/hd-keyring-seed.js'; -import { signMessageWithMessageSigningKey } from '../../shared/utils/message-signing.js'; +import { createSnapSignMessageRequest } from '../authentication/auth-snap-requests.js'; import type { AuthenticationControllerGetBearerTokenAction, AuthenticationControllerGetSessionProfileAction, @@ -170,7 +169,8 @@ export type Actions = export type AllowedActions = // Keyring Requests | KeyringControllerGetStateAction - | KeyringControllerWithKeyringV2UnsafeAction + // Snap Requests + | SnapControllerHandleRequestAction // Auth Requests | AuthenticationControllerGetBearerTokenAction | AuthenticationControllerGetSessionProfileAction @@ -251,7 +251,7 @@ export class UserStorageController extends BaseController< // signature and leak data across each other's user storage. #storageKeyCache: Record = {}; - #signMessageCache: Record = {}; + #snapSignMessageCache: Record = {}; readonly #keyringController = { setupLockedStateSubscriptions: () => { @@ -324,7 +324,10 @@ export class UserStorageController extends BaseController< ); }, signMessage: (message: string, entropySourceId?: string) => - this.#signMessage(message, entropySourceId), + this.#snapSignMessage( + message as `metamask:${string}`, + entropySourceId, + ), }, }, { @@ -580,35 +583,34 @@ export class UserStorageController extends BaseController< } /** - * Signs a `metamask:…` message with the native SIP-6 message-signing key - * (same key as `@metamask/message-signing-snap` with empty salt). + * Signs a specific message using an underlying auth snap. * * @param message - A specific tagged message to sign. * @param entropySourceId - The entropy source ID used to derive the key, * when multiple sources are available (Multi-SRP). - * @returns Compact secp256k1 signature hex. + * @returns A Signature created by the snap. */ - async #signMessage( - message: string, + async #snapSignMessage( + message: `metamask:${string}`, entropySourceId?: string, ): Promise { if (!this.#isUnlocked) { - throw new Error('#signMessage - unable to proceed, wallet is locked'); + throw new Error( + '#snapSignMessage - unable to call snap, wallet is locked', + ); } - const cacheKey = this.#scopedCacheKey( - message as `metamask:${string}`, - entropySourceId, - ); - if (this.#signMessageCache[cacheKey]) { - return this.#signMessageCache[cacheKey]; + const cacheKey = this.#scopedCacheKey(message, entropySourceId); + if (this.#snapSignMessageCache[cacheKey]) { + return this.#snapSignMessageCache[cacheKey]; } - const resolvedId = entropySourceId ?? this.#getPrimaryEntropySourceId(); - const seed = await getHdKeyringSeed(this.messenger, resolvedId); - const result = await signMessageWithMessageSigningKey(message, seed); + const result = (await this.messenger.call( + 'SnapController:handleRequest', + createSnapSignMessageRequest(message, entropySourceId), + )) as string; - this.#signMessageCache[cacheKey] = result; + this.#snapSignMessageCache[cacheKey] = result; return result; } diff --git a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts index e18212b3fbc..69b42f9566d 100644 --- a/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts +++ b/packages/profile-sync-controller/src/controllers/user-storage/__fixtures__/mockMessenger.ts @@ -7,7 +7,6 @@ import type { NotNamespacedBy, } from '@metamask/messenger'; -import { signMessageWithMessageSigningKey } from '../../../shared/utils/message-signing.js'; import { MOCK_LOGIN_RESPONSE } from '../../authentication/mocks/index.js'; import type { AllowedActions, @@ -16,8 +15,6 @@ import type { } from '../index.js'; import { MOCK_STORAGE_KEY_SIGNATURE } from '../mocks/index.js'; -const MOCK_HD_SEED = new Uint8Array(64).fill(1); - const controllerName = 'UserStorageController'; type GetHandler = Extract< @@ -86,7 +83,7 @@ export function createCustomUserStorageMessenger(props?: { messenger, actions: [ 'KeyringController:getState', - 'KeyringController:withKeyringV2Unsafe', + 'SnapController:handleRequest', 'AuthenticationController:getBearerToken', 'AuthenticationController:getSessionProfile', 'AuthenticationController:isSignedIn', @@ -123,8 +120,10 @@ export function mockUserStorageMessenger( const { baseMessenger, messenger } = overrideMessengers ?? createCustomUserStorageMessenger(); - const mockSignMessage = jest.mocked(signMessageWithMessageSigningKey); - mockSignMessage.mockReset().mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); + const mockSnapGetPublicKey = jest.fn().mockResolvedValue('MOCK_PUBLIC_KEY'); + const mockSnapSignMessage = jest + .fn() + .mockResolvedValue(MOCK_STORAGE_KEY_SIGNATURE); const mockAuthGetBearerToken = typedMockFn( 'AuthenticationController:getBearerToken', @@ -163,32 +162,27 @@ export function mockUserStorageMessenger( ], }); - const mockWithKeyringV2Unsafe = jest - .fn() - .mockImplementation( - async ( - _selector: { id: string }, - operation: (context: { - keyring: { type: string; seed?: Uint8Array }; - metadata: { id: string; name: string }; - }) => Promise, - ) => { - return operation({ - keyring: { type: 'hd', seed: MOCK_HD_SEED }, - metadata: { id: 'mock', name: '' }, - }); - }, - ); - const mockAccountsListAccounts = jest.fn(); - jest.spyOn(messenger, 'call').mockImplementation((...args: unknown[]) => { + jest.spyOn(messenger, 'call').mockImplementation((...args) => { const typedArgs = args as unknown as CallParams; const [actionType] = typedArgs; - if (actionType === 'KeyringController:withKeyringV2Unsafe') { - const [, selector, operation] = typedArgs; - return mockWithKeyringV2Unsafe(selector, operation); + if (actionType === 'SnapController:handleRequest') { + const [, params] = typedArgs; + if (params.request.method === 'getPublicKey') { + return mockSnapGetPublicKey(); + } + + if (params.request.method === 'signMessage') { + return mockSnapSignMessage(); + } + + throw new Error( + `MOCK_FAIL - unsupported SnapController:handleRequest call: ${ + params.request.method as string + }`, + ); } if (actionType === 'AuthenticationController:getBearerToken') { @@ -219,7 +213,8 @@ export function mockUserStorageMessenger( return { baseMessenger, messenger, - mockSignMessage, + mockSnapGetPublicKey, + mockSnapSignMessage, mockAuthGetBearerToken, mockAuthGetSessionProfile, mockAuthPerformSignIn, @@ -228,7 +223,6 @@ export function mockUserStorageMessenger( mockKeyringAddAccounts, mockKeyringGetState, mockWithKeyringSelector, - mockWithKeyringV2Unsafe, mockAccountsListAccounts, }; } diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts index dc9f73fcfb6..e8e74f363e2 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.ts @@ -13,6 +13,7 @@ export const USER_STORAGE_FEATURE_NAMES = { notifications: 'notifications', accounts: 'accounts_v2', addressBook: 'addressBook', + rampsAutoramps: 'rampsAutoramps', }; export type UserStorageGenericFeatureName = string; diff --git a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts deleted file mode 100644 index f0e42c063fe..00000000000 --- a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { KeyringType } from '@metamask/keyring-api/v2'; - -import { getHdKeyringSeed } from './hd-keyring-seed.js'; - -describe('getHdKeyringSeed', () => { - it('returns the HD keyring seed for a matching entropy source id', async () => { - const seed = new Uint8Array(64).fill(7); - const messenger = { - call: jest.fn(async (_action, _selector, operation) => - operation({ - keyring: { type: KeyringType.Hd, seed }, - metadata: { id: 'entropy-1', name: '' }, - }), - ), - }; - - expect(await getHdKeyringSeed(messenger, 'entropy-1')).toBe(seed); - expect(messenger.call).toHaveBeenCalledWith( - 'KeyringController:withKeyringV2Unsafe', - { id: 'entropy-1' }, - expect.any(Function), - ); - }); - - it('throws when the keyring is not an HD keyring with a seed', async () => { - const messenger = { - call: jest.fn(async (_action, _selector, operation) => - operation({ - keyring: { type: KeyringType.Snap }, - metadata: { id: 'missing', name: '' }, - }), - ), - }; - - await expect(getHdKeyringSeed(messenger, 'missing')).rejects.toThrow( - 'Entropy source not found or is not an HD keyring.', - ); - }); -}); diff --git a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts b/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts deleted file mode 100644 index f4a214ed821..00000000000 --- a/packages/profile-sync-controller/src/shared/utils/hd-keyring-seed.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { HdKeyring } from '@metamask/eth-hd-keyring/v2'; -import type { KeyringType } from '@metamask/keyring-api/v2'; -import type { KeyringControllerWithKeyringV2UnsafeAction } from '@metamask/keyring-controller'; -import type { Messenger } from '@metamask/messenger'; - -/** - * HD keyring seed access for native SIP-6 message signing. - * - * This mirrors `@metamask/snaps-rpc-methods` `getMnemonicSeed` (when called - * with an entropy source id): - * `KeyringController:withKeyringV2Unsafe` → assert HD → return `keyring.seed`. - * - * Those helpers are not a public export of snaps-rpc-methods, so this thin wrapper lives here. - * - * @see https://github.com/MetaMask/snaps/blob/main/packages/snaps-rpc-methods/src/utils.ts - */ - -/** - * V2 HD keyring type (`KeyringType.Hd`). The template type keeps the literal - * aligned with `@metamask/keyring-api/v2` without a runtime dependency. - */ -const HD_KEYRING_TYPE: `${KeyringType.Hd}` = 'hd'; - -const ENTROPY_SOURCE_NOT_FOUND_ERROR = - 'Entropy source not found or is not an HD keyring.'; - -/** - * Structural messenger shape for `withKeyringV2Unsafe`. - * - * Not `Messenger` because - * `Messenger` is invariant in its action union — Auth / UserStorage messengers - * (which allow additional actions) are not assignable to that narrow type. - */ -type MessengerWithKeyringV2Unsafe = { - call: ( - ...args: Parameters< - Messenger['call'] - > - ) => unknown; -}; - -/** - * Reads the BIP-39 seed for an HD keyring entropy source via - * `KeyringController:withKeyringV2Unsafe`. - * - * Equivalent to snaps-rpc-methods `getMnemonicSeed(messenger, source)` for a - * concrete entropy source id. - * - * @param messenger - Messenger that can call `withKeyringV2Unsafe`. - * @param entropySourceId - Keyring metadata ID (SIP-30 entropy source). - * @returns The HD keyring seed. - * @throws If the keyring is missing or is not an HD keyring with a seed. - */ -export async function getHdKeyringSeed( - messenger: MessengerWithKeyringV2Unsafe, - entropySourceId: string, -): Promise { - try { - const keyringData = (await messenger.call( - 'KeyringController:withKeyringV2Unsafe', - { id: entropySourceId }, - async ({ keyring }) => { - const hdKeyring = keyring as HdKeyring; - return { type: hdKeyring.type, seed: hdKeyring.seed }; - }, - )) as { type: string; seed?: Uint8Array | null }; - - if (keyringData.type !== HD_KEYRING_TYPE || !keyringData.seed) { - throw new Error(ENTROPY_SOURCE_NOT_FOUND_ERROR); - } - - return keyringData.seed; - } catch { - throw new Error(ENTROPY_SOURCE_NOT_FOUND_ERROR); - } -} diff --git a/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts b/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts deleted file mode 100644 index 9134932423b..00000000000 --- a/packages/profile-sync-controller/src/shared/utils/message-signing.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { bytesToHex } from '@metamask/utils'; - -import { - deriveMessageSigningPrivateKey, - deriveSip6PrivateKey, - getMessageSigningPublicKey, - MESSAGE_SIGNING_SNAP_ID, - signMessageWithMessageSigningKey, -} from './message-signing.js'; - -// Same seed as `@metamask/snaps-utils` TEST_SECRET_RECOVERY_PHRASE_SEED_BYTES -// (`test test test test test test test test test test test ball`). -const TEST_SEED = new Uint8Array([ - 44, 232, 45, 62, 149, 146, 73, 117, 90, 217, 78, 33, 68, 145, 185, 177, 102, - 61, 41, 58, 21, 196, 248, 21, 155, 72, 140, 191, 191, 66, 144, 46, 47, 188, - 165, 16, 149, 48, 252, 179, 255, 31, 120, 228, 174, 203, 27, 194, 102, 9, 173, - 1, 47, 174, 216, 184, 227, 85, 112, 105, 241, 209, 73, 65, -]); - -// From `@metamask/snaps-rpc-methods` SIP-6 ENTROPY_VECTORS. -const SIP6_VECTORS = [ - { - snapId: 'foo', - entropy: - '0x8bbb59ec55a4a8dd5429268e367ebbbe54eee7467c0090ca835c64d45c33a155', - }, - { - snapId: 'bar', - entropy: - '0xbdae5c0790d9189d8ae27fd4860b3b57bab420b6594c420ae9ae3a9f87c1ea14', - }, - { - snapId: 'foo', - salt: 'bar', - entropy: - '0x59cbec1fa877ecb38d88c3a2326b23bff374954b39ad9482c9b082306ac4b3ad', - }, - { - snapId: 'bar', - salt: 'baz', - entropy: - '0x814c1f121eb4067d1e1d177246461e8a1cc6a1b1152756737aba7fa9c2161ba2', - }, -] as const; - -describe('message-signing SIP-6 helpers', () => { - it('exports the message-signing snap ID used as SIP-6 input', () => { - expect(MESSAGE_SIGNING_SNAP_ID).toBe('npm:@metamask/message-signing-snap'); - }); - - it.each(SIP6_VECTORS)( - 'matches SIP-6 entropy vector for snapId=$snapId salt=$salt', - async ({ snapId, salt, entropy }) => { - const privateKey = await deriveSip6PrivateKey({ - seed: TEST_SEED, - input: snapId, - salt, - }); - expect(bytesToHex(privateKey)).toBe(entropy); - }, - ); - - it('derives a stable public key for the message-signing snap id', async () => { - const publicKey = await getMessageSigningPublicKey(TEST_SEED); - expect(publicKey).toMatch(/^0x[0-9a-f]{66}$/u); - - const again = await getMessageSigningPublicKey(TEST_SEED); - expect(again).toBe(publicKey); - }); - - it('signs metamask messages with a compact secp256k1 signature', async () => { - const signature = await signMessageWithMessageSigningKey( - 'metamask:test', - TEST_SEED, - ); - expect(signature).toMatch(/^0x[0-9a-f]{128}$/u); - - const again = await signMessageWithMessageSigningKey( - 'metamask:test', - TEST_SEED, - ); - expect(again).toBe(signature); - }); - - it('uses empty salt by default (internal metamask origin parity)', async () => { - const withDefaultSalt = await deriveMessageSigningPrivateKey(TEST_SEED); - const withExplicitEmptySalt = await deriveMessageSigningPrivateKey( - TEST_SEED, - '', - ); - expect(bytesToHex(withDefaultSalt)).toBe(bytesToHex(withExplicitEmptySalt)); - }); - - it('derives SIP-6 entropy when crypto.subtle exists without importKey', async () => { - const originalDescriptor = Object.getOwnPropertyDescriptor( - globalThis, - 'crypto', - ); - Object.defineProperty(globalThis, 'crypto', { - configurable: true, - value: { subtle: { digest: async () => new ArrayBuffer(0) } }, - }); - - try { - const privateKey = await deriveSip6PrivateKey({ - seed: TEST_SEED, - input: 'foo', - }); - expect(bytesToHex(privateKey)).toBe(SIP6_VECTORS[0].entropy); - } finally { - if (originalDescriptor) { - Object.defineProperty(globalThis, 'crypto', originalDescriptor); - } else { - Reflect.deleteProperty(globalThis, 'crypto'); - } - } - }); -}); diff --git a/packages/profile-sync-controller/src/shared/utils/message-signing.ts b/packages/profile-sync-controller/src/shared/utils/message-signing.ts deleted file mode 100644 index 12cd67e8507..00000000000 --- a/packages/profile-sync-controller/src/shared/utils/message-signing.ts +++ /dev/null @@ -1,185 +0,0 @@ -import type { HardenedBIP32Node } from '@metamask/key-tree'; -import { SLIP10Node } from '@metamask/key-tree'; -import { - assert, - bytesToHex, - concatBytes, - createDataView, - hexToBytes, - stringToBytes, -} from '@metamask/utils'; -import { secp256k1 } from '@noble/curves/secp256k1'; -import { hmac } from '@noble/hashes/hmac'; -import { sha256, sha512 } from '@noble/hashes/sha2'; -import { keccak_256 as keccak256 } from '@noble/hashes/sha3'; - -/** - * Native SIP-6 message-signing helpers for AuthenticationController / - * UserStorageController. - * - * These are intentional copies of the message-signing snap crypto path so auth - * and user-storage can derive/sign without booting - * `npm:@metamask/message-signing-snap`. Behavior must stay byte-identical to: - * - * - SIP-6 derivation: - * `@metamask/snaps-rpc-methods` `deriveEntropyFromSeed` / - * `getEntropyDerivationPath` / `getDerivationPathArray` - * (https://github.com/MetaMask/snaps/blob/main/packages/snaps-rpc-methods/src/utils.ts) - * - Magic constant: - * `@metamask/snaps-utils` `SIP_6_MAGIC_VALUE` - * - Pubkey + `metamask:…` signing: - * `@metamask/message-signing-snap` `getPublicEntropyKey` / - * `signMessageWithEntropyKey` - * (https://github.com/MetaMask/message-signing-snap/blob/main/src/entropy-keys.ts) - * - * `deriveEntropyFromSeed` is not a public export of - * `@metamask/snaps-rpc-methods` today (and no core package depends on that - * package), so the SIP-6 math is vendored here rather than imported. - */ - -/** - * Snap ID used as the SIP-6 `input` so derived keys match - * `@metamask/message-signing-snap` (`snap_getEntropy` origin). - */ -export const MESSAGE_SIGNING_SNAP_ID = 'npm:@metamask/message-signing-snap'; - -/** - * Copy of `@metamask/snaps-utils` `SIP_6_MAGIC_VALUE` - * (`0xd36e6170 - 0x80000000`). - * - * @see https://metamask.github.io/SIPs/SIPS/sip-6 - */ -const SIP_6_MAGIC_VALUE = `1399742832'` as `${number}'`; - -const HARDENED_VALUE = 0x80000000; - -/** - * HMAC-SHA-512 for `@metamask/key-tree`. - * - * Passed into `SLIP10Node.fromSeed` so SIP-6 never uses Web Crypto. - * `@metamask/key-tree` treats any `crypto.subtle` as complete and then HMAC - * via `importKey` / `sign`. React Native only implements `digest`; its - * SubtleCrypto cannot HMAC. Noble HMAC is byte-identical without SubtleCrypto. - */ -const NOBLE_HMAC_SHA512 = { - hmacSha512: async (key: Uint8Array, data: Uint8Array): Promise => - hmac(sha512, key, data), -}; - -/** - * Copy of `@metamask/snaps-rpc-methods` `getDerivationPathArray`. - * - * Maps a 32-byte hash to eight hardened BIP-32 indices for `@metamask/key-tree`. - * - * @param hash - 32-byte hash. - * @returns Hardened BIP-32 path nodes. - */ -function getDerivationPathArray(hash: Uint8Array): HardenedBIP32Node[] { - const array: HardenedBIP32Node[] = []; - const view = createDataView(hash); - - for (let index = 0; index < 8; index++) { - const uint32 = view.getUint32(index * 4); - // eslint-disable-next-line no-bitwise - const pathIndex = (uint32 | HARDENED_VALUE) >>> 0; - array.push(`bip32:${pathIndex - HARDENED_VALUE}'` as const); - } - - return array; -} - -/** - * Copy of `@metamask/snaps-rpc-methods` `deriveEntropyFromSeed` (SIP-6), - * returning raw private-key bytes instead of a `0x`-prefixed hex string. - * - * @param options - Derivation options. - * @param options.seed - BIP-39 mnemonic seed. - * @param options.input - SIP-6 input (snap ID for `snap_getEntropy`). - * @param options.salt - Optional salt. Internal auth uses `''`. - * @returns 32-byte private key. - */ -export async function deriveSip6PrivateKey({ - seed, - input, - salt = '', -}: { - seed: Uint8Array; - input: string; - salt?: string; -}): Promise { - const hash = keccak256( - concatBytes([stringToBytes(input), keccak256(stringToBytes(salt))]), - ); - const computedDerivationPath = getDerivationPathArray(hash); - - const { privateKey } = await SLIP10Node.fromSeed( - { - derivationPath: [ - seed, - `bip32:${SIP_6_MAGIC_VALUE}`, - ...computedDerivationPath, - ], - curve: 'secp256k1', - }, - NOBLE_HMAC_SHA512, - ); - - assert(privateKey, 'Failed to derive SIP-6 entropy.'); - return hexToBytes(privateKey); -} - -/** - * Derives the message-signing private key via SIP-6, matching - * `snap_getEntropy` for the message-signing snap with empty salt - * (internal `metamask` origin). - * - * @param seed - BIP-39 mnemonic seed from an HD keyring. - * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. - * @returns 32-byte private key. - */ -export async function deriveMessageSigningPrivateKey( - seed: Uint8Array, - salt = '', -): Promise { - return deriveSip6PrivateKey({ - seed, - input: MESSAGE_SIGNING_SNAP_ID, - salt, - }); -} - -/** - * Copy of message-signing-snap `getPublicEntropyKey`: secp256k1 pubkey hex - * for the SIP-6 message-signing private key. - * - * @param seed - BIP-39 mnemonic seed from an HD keyring. - * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. - * @returns Public key hex with `0x` prefix. - */ -export async function getMessageSigningPublicKey( - seed: Uint8Array, - salt = '', -): Promise { - const privateKey = await deriveMessageSigningPrivateKey(seed, salt); - return bytesToHex(secp256k1.getPublicKey(privateKey)); -} - -/** - * Copy of message-signing-snap `signMessageWithEntropyKey`: sha256(message) - * then compact secp256k1 signature. - * - * @param message - Message to sign (must be validated by the caller). - * @param seed - BIP-39 mnemonic seed from an HD keyring. - * @param salt - Optional SIP-6 salt. Auth / user-storage use `''`. - * @returns Compact secp256k1 signature hex with `0x` prefix. - */ -export async function signMessageWithMessageSigningKey( - message: string, - seed: Uint8Array, - salt = '', -): Promise { - const privateKey = await deriveMessageSigningPrivateKey(seed, salt); - const digest = sha256(message); - const signature = secp256k1.sign(digest, privateKey); - return `0x${signature.toCompactHex()}`; -} diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c3573e3e5c8..97b6eb6e827 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,7 +9,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) +- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from Profile Sync (`AuthenticationController:getSessionProfile`) via `NeoBankService:getCustomerByExternalId`, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when the wallet is not signed in or no MoonPay customer is mapped to the external id. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation and Money Account wallet registration. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id via Profile Sync → neobank-proxy external-id lookup, signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ([#9853](https://github.com/MetaMask/core/pull/9853)) + +### Changed + +- Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. ([#9859](https://github.com/MetaMask/core/pull/9859), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ([#9853](https://github.com/MetaMask/core/pull/9853)) + +### Fixed + +- Keep the local `customerId` / `walletAddress` when a remote autoramp snapshot omits or blanks them. The proxy sends empty identity fields on partial status pushes, and `applyAutorampRemoteStatus` / `mapNeoBankAutorampToRemoteSnapshot` treated those as a clear, wiping valid local values during refresh-on-load and websocket pushes. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [20.0.0] diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts new file mode 100644 index 00000000000..956b7f2b6b4 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -0,0 +1,147 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NeoBankService } from './NeoBankService.js'; + +/** + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceGetAutorampAction = { + type: `NeoBankService:getAutoramp`; + handler: NeoBankService['getAutoramp']; +}; + +/** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceRegisterPixAddressAction = { + type: `NeoBankService:registerPixAddress`; + handler: NeoBankService['registerPixAddress']; +}; + +/** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteAction = { + type: `NeoBankService:getAutorampQuote`; + handler: NeoBankService['getAutorampQuote']; +}; + +/** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceCreateAutorampAction = { + type: `NeoBankService:createAutoramp`; + handler: NeoBankService['createAutoramp']; +}; + +/** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteForAutorampAction = { + type: `NeoBankService:getAutorampQuoteForAutoramp`; + handler: NeoBankService['getAutorampQuoteForAutoramp']; +}; + +/** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceAttachAutorampQuoteAction = { + type: `NeoBankService:attachAutorampQuote`; + handler: NeoBankService['attachAutorampQuote']; +}; + +/** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetCustomerByExternalIdAction = { + type: `NeoBankService:getCustomerByExternalId`; + handler: NeoBankService['getCustomerByExternalId']; +}; + +/** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ +export type NeoBankServiceGetMoonpayCustomerIdAction = { + type: `NeoBankService:getMoonpayCustomerId`; + handler: NeoBankService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type NeoBankServiceGetWalletRegistrationStatusAction = { + type: `NeoBankService:getWalletRegistrationStatus`; + handler: NeoBankService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type NeoBankServiceRegisterSelfHostedWalletAction = { + type: `NeoBankService:registerSelfHostedWallet`; + handler: NeoBankService['registerSelfHostedWallet']; +}; + +/** + * Union of all NeoBankService action types. + */ +export type NeoBankServiceMethodActions = + | NeoBankServiceGetAutorampAction + | NeoBankServiceRegisterPixAddressAction + | NeoBankServiceGetAutorampQuoteAction + | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetAutorampQuoteForAutorampAction + | NeoBankServiceAttachAutorampQuoteAction + | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetMoonpayCustomerIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts new file mode 100644 index 00000000000..94cc82f2f05 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -0,0 +1,588 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import { + mapNeoBankAutorampToRemoteSnapshot, + NeoBankService, +} from './NeoBankService.js'; +import type { NeoBankServiceMessenger } from './NeoBankService.js'; +import { RampsEnvironment } from './RampsService.js'; + +const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; + +/** + * Builds a NeoBankService with AuthenticationController bearer auth stubbed. + * + * @param options - Optional constructor overrides. + * @param options.environment - Ramp environment for host selection. + * @param options.baseUrlOverride - Overrides the environment-derived host. + * @param options.omitDefaults - Pass `true` to exercise constructor defaulted + * parameters (`environment`, `policyOptions`). + * @param options.canonicalProfileId - Canonical profile id returned by the + * stubbed `AuthenticationController:getSessionProfile` (wallet registration). + * @returns Service instance for the test. + */ +function createService(options?: { + environment?: RampsEnvironment; + baseUrlOverride?: string; + omitDefaults?: boolean; + canonicalProfileId?: string; +}): NeoBankService { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'test-token', + ); + const canonicalProfileId = + options?.canonicalProfileId ?? 'canonical-profile-1'; + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: canonicalProfileId, + canonicalProfileId, + metaMetricsId: 'mm-1', + }) as never, + ); + + const messenger = new Messenger({ + namespace: 'NeoBankService', + parent: rootMessenger, + }) as unknown as NeoBankServiceMessenger; + rootMessenger.delegate({ + messenger, + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + ], + }); + + if (options?.omitDefaults) { + return new NeoBankService({ + messenger, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + baseUrlOverride: options.baseUrlOverride, + }); + } + + return new NeoBankService({ + messenger, + environment: options?.environment ?? RampsEnvironment.Staging, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + policyOptions: { maxRetries: 0 }, + baseUrlOverride: options?.baseUrlOverride, + }); +} + +describe('NeoBankService', () => { + afterEach(() => { + cleanAll(); + }); + + describe('mapNeoBankAutorampToRemoteSnapshot', () => { + it('maps MoonPay-shaped fields into a remote snapshot', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + wallet_address: '0xabc', + deposit_rails: [{ type: 'Iban' }], + }), + ).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: 'Approved', + depositRailsSummary: { ready: true }, + }); + }); + + it('falls back to recipient_account.address when wallet_address is absent', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Pending', + recipient_account: { address: '0xfrom-recipient' }, + }), + ).toMatchObject({ + walletAddress: '0xfrom-recipient', + depositRailsSummary: undefined, + }); + }); + + it('marks deposit rails not ready when Approved without rails', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + }), + ).toMatchObject({ + depositRailsSummary: { ready: false }, + }); + }); + }); + + describe('getAutoramp', () => { + it('gets /neobank/autoramps/{id} with bearer auth', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + wallet_address: '0xabc', + }); + + const service = createService(); + const snapshot = await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-1', + customerId: 'cust-1', + status: 'Authorized', + walletAddress: '0xabc', + }); + }); + + it('throws HttpError when the proxy returns a non-2xx status', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/missing/u) + .reply(404); + + const service = createService(); + await expect(service.getAutoramp('missing')).rejects.toThrow( + /failed with status '404'/u, + ); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { status: 'Authorized' }); + + const service = createService(); + await expect(service.getAutoramp('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('registerPixAddress', () => { + it('posts /neobank/addresses/pix with JSON body and bearer auth', async () => { + const body = { + type: 'Pix', + pix_key: 'user@example.com', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { id: 'addr-1', ...body }); + + const service = createService(); + const result = await service.registerPixAddress(body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'addr-1' }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', { pix_key: 'k' }) + .query(true) + .matchHeader('Idempotency-Key', 'idem-1') + .reply(200, { id: 'addr-1' }); + + const service = createService(); + await service.registerPixAddress( + { pix_key: 'k' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('getAutorampQuote', () => { + it('gets /neobank/autoramps/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '100' && + query.currency === 'BRL' && + typeof query.sdk === 'string' && + typeof query.controller === 'string' && + query.context === 'test' + ); + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-1', amount: '100' }); + + const service = createService(); + const result = await service.getAutorampQuote({ + amount: '100', + currency: 'BRL', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-1' }); + }); + }); + + describe('createAutoramp', () => { + it('posts /neobank/autoramps and maps the Autoramp response', async () => { + const body = { + signed_quote: 'sig', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(201, { + id: 'ar-new', + customer_id: 'cust-1', + status: 'Pending', + wallet_address: '0xdef', + }); + + const service = createService(); + const snapshot = await service.createAutoramp(body); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-new', + customerId: 'cust-1', + status: 'Pending', + walletAddress: '0xdef', + }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', { signed_quote: 'sig' }) + .query(true) + .matchHeader('Idempotency-Key', 'create-idem') + .reply(201, { + id: 'ar-2', + customer_id: 'cust-1', + status: 'Pending', + }); + + const service = createService(); + await service.createAutoramp( + { signed_quote: 'sig' }, + { idempotencyKey: 'create-idem' }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .post('/neobank/autoramps') + .query(true) + .reply(201, { status: 'Pending' }); + + const service = createService(); + await expect( + service.createAutoramp({ signed_quote: 'sig' }), + ).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('getAutorampQuoteForAutoramp', () => { + it('gets /neobank/autoramps/{id}/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query((query) => { + return query.amount === '50' && query.context === 'test'; + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-2' }); + + const service = createService(); + const result = await service.getAutorampQuoteForAutoramp('ar-1', { + amount: '50', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-2' }); + }); + }); + + describe('attachAutorampQuote', () => { + it('posts /neobank/autoramps/{id}/quotes with JSON body', async () => { + const body = { signed_quote: 'attach-sig' }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps/ar-1/quotes', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { quote_id: 'q-attached' }); + + const service = createService(); + const result = await service.attachAutorampQuote('ar-1', body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-attached' }); + }); + }); + + describe('getCustomerByExternalId', () => { + it('gets /neobank/customers/{external_id}/external', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/customers/ext-1/external') + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { id: 'cust-1', external_id: 'ext-1' }); + + const service = createService(); + const result = await service.getCustomerByExternalId('ext-1'); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'cust-1', external_id: 'ext-1' }); + }); + }); + + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { + nock(STAGING_BASE) + .get('/neobank/customers/canonical-profile-1/external') + .matchHeader('authorization', 'Bearer test-token') + .reply(200, { + id: 'iron-customer-1', + external_id: 'canonical-profile-1', + }); + + const service = createService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status for a customer', async () => { + nock(STAGING_BASE) + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) + .reply(200, []); + + const service = createService(); + + expect( + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { + nock(STAGING_BASE) + .post( + '/neobank/addresses/crypto/selfhosted', + { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }, + { reqheaders: { 'idempotency-key': 'idem-1' } }, + ) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const service = createService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + idempotencyKey: 'idem-1', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + + it('uses the baseUrlOverride host for wallet routes', async () => { + const overrideUrl = 'https://on-ramp.dev-api.cx.metamask.io'; + nock(overrideUrl) + .get('/neobank/customers/canonical-profile-1/external') + .reply(200, { id: 'iron-customer-1' }); + + const service = createService({ baseUrlOverride: overrideUrl }); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('throws when the session profile has no usable external id', async () => { + const service = createService({ canonicalProfileId: '' }); + + await expect(service.getMoonpayCustomerId()).rejects.toThrow( + /Unable to resolve MetaMask canonical profile id/u, + ); + }); + }); + + describe('environments and policy hooks', () => { + it.each([ + [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'], + [RampsEnvironment.Development, 'https://on-ramp.dev-api.cx.metamask.io'], + [RampsEnvironment.Local, 'http://localhost:3000'], + ] as const)( + 'uses the %s host for getAutoramp', + async (environment, host) => { + const scope = nock(host) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ environment }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }, + ); + + it('uses constructor defaults for environment and policyOptions', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ omitDefaults: true }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('calls getAutorampQuote and getAutorampQuoteForAutoramp without query', async () => { + const quoteScope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query(true) + .reply(200, { quote_id: 'q-default' }); + const forAutorampScope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query(true) + .reply(200, { quote_id: 'q-for-ar' }); + + const service = createService(); + await service.getAutorampQuote(); + await service.getAutorampQuoteForAutoramp('ar-1'); + + expect(quoteScope.isDone()).toBe(true); + expect(forAutorampScope.isDone()).toBe(true); + }); + + it('uses baseUrlOverride when provided', async () => { + const scope = nock('http://custom-neobank.test') + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ + baseUrlOverride: 'http://custom-neobank.test', + }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws for an invalid environment', async () => { + await expect( + createService({ + environment: 'bogus' as RampsEnvironment, + }).getAutoramp('ar-1'), + ).rejects.toThrow(/Invalid environment/u); + }); + + it('throws HttpError on non-2xx POST responses', async () => { + nock(STAGING_BASE) + .post('/neobank/addresses/pix') + .query(true) + .reply(422, { error: 'bad' }); + + const service = createService(); + await expect( + service.registerPixAddress({ pix_key: 'k' }), + ).rejects.toThrow(/failed with status '422'/u); + }); + + it('omits nullish query values when building quote URLs', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '10' && + query.currency === undefined && + query.optional === undefined + ); + }) + .reply(200, { quote_id: 'q-nullish' }); + + const service = createService(); + await service.getAutorampQuote({ + amount: '10', + currency: undefined, + optional: null, + }); + + expect(scope.isDone()).toBe(true); + }); + + it('registers onRetry, onBreak, and onDegraded listeners', () => { + const service = createService(); + const onRetry = jest.fn(); + const onBreak = jest.fn(); + const onDegraded = jest.fn(); + + const retrySub = service.onRetry(onRetry); + const breakSub = service.onBreak(onBreak); + const degradedSub = service.onDegraded(onDegraded); + + expect(typeof retrySub.dispose).toBe('function'); + expect(typeof breakSub.dispose).toBe('function'); + expect(typeof degradedSub.dispose).toBe('function'); + + retrySub.dispose(); + breakSub.dispose(); + degradedSub.dispose(); + }); + }); +}); diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts new file mode 100644 index 00000000000..bfefa07ac36 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -0,0 +1,599 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { + createServicePolicy, + handleWhen, + HttpError, +} from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; + +import packageJson from '../package.json'; +import type { + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; +import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; + +/** + * Name of the NeoBankService messenger namespace. + */ +export const serviceName = 'NeoBankService'; + +/** + * Determines whether a failed neo-bank request is worth re-issuing. + * + * 4xx responses describe the request or the account's state (e.g. 403 + * "Customer is not active", 422 validation), so repeating them only multiplies + * the same rejection. 429 stays retryable alongside 5xx and non-HTTP + * network/timeout errors. + * + * @param error - Error thrown while performing the request. + * @returns `true` when the error is worth retrying. + */ +function isRetryableError(error: unknown): boolean { + if (error instanceof HttpError) { + if (error.httpStatus === 429) { + return true; + } + return error.httpStatus < 400 || error.httpStatus >= 500; + } + return true; +} + +/** + * Raw autoramp payload from the MetaMask Ramp API neo-bank proxy. + * Shape mirrors MoonPay Enterprise `GET /api/autoramps/{autoramp_id}`. + * The Ramp API handles partner auth / headers; the client only sends the + * MetaMask bearer token. + */ +export type NeoBankAutorampResponse = { + id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field + customer_id: string; + status: string; + /** + * Destination wallet when present on the proxy response. + * Field name may evolve with the Ramp API contract. + */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field + wallet_address?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field + recipient_account?: { + address?: string; + }; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field + deposit_rails?: unknown[]; +}; + +/** + * Optional headers for neo-bank mutating requests. + */ +export type NeoBankRequestOptions = { + /** + * Forwarded as `Idempotency-Key` when set (MoonPay requires it on some POSTs; + * neobank-proxy generates one when omitted). + */ + idempotencyKey?: string; +}; + +/** + * Query string values accepted by neo-bank GET helpers. + */ +export type NeoBankQueryParams = Record< + string, + string | number | boolean | undefined | null +>; + +export type GetWalletRegistrationStatusParams = { + customerId: string; + address: string; +}; + +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; + /** + * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable + * key across retries of the same ownership body. + */ + idempotencyKey?: string; +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'getAutoramp', + 'registerPixAddress', + 'getAutorampQuote', + 'createAutoramp', + 'getAutorampQuoteForAutoramp', + 'attachAutorampQuote', + 'getCustomerByExternalId', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', +] as const; + +/** + * Actions that {@link NeoBankService} exposes to other consumers. + */ +export type NeoBankServiceActions = NeoBankServiceMethodActions; + +type AllowedActions = + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; + +export type NeoBankServiceEvents = never; + +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link NeoBankService}. + */ +export type NeoBankServiceMessenger = Messenger< + typeof serviceName, + NeoBankServiceActions | AllowedActions, + NeoBankServiceEvents | AllowedEvents +>; + +/** + * Builds a path under the neobank-proxy global prefix. + * + * Live neobank-proxy (#1124) mounts routes at `/neobank` on the on-ramp.api + * host (ALB path routing, no rewrite). Prefer this over `/api/v2/...` so Core + * matches the proxy that ships. + * + * @param path - Path under `/neobank` (no leading slash). + * @returns Absolute path segment for URL join against the Ramp API host. + */ +function getNeoBankPath(path: string): string { + return `neobank/${path.replace(/^\//u, '')}`; +} + +/** + * Resolves the Ramp API host for neo-bank calls (same hosts as {@link RampsService}). + * + * @param environment - Ramp environment. + * @returns Base URL. + */ +function getBaseUrl(environment: RampsEnvironment): string { + switch (environment) { + case RampsEnvironment.Production: + return 'https://on-ramp.api.cx.metamask.io'; + case RampsEnvironment.Staging: + return 'https://on-ramp.uat-api.cx.metamask.io'; + case RampsEnvironment.Development: + return 'https://on-ramp.dev-api.cx.metamask.io'; + case RampsEnvironment.Local: + return 'http://localhost:3000'; + default: + throw new Error(`Invalid environment: ${String(environment)}`); + } +} + +/** + * Maps a Ramp API / MoonPay-shaped autoramp response into the local remote snapshot. + * + * @param response - Proxy response body. + * @returns Snapshot consumed by {@link applyAutorampRemoteStatus}. + */ +export function mapNeoBankAutorampToRemoteSnapshot( + response: NeoBankAutorampResponse, +): AutorampRemoteSnapshot { + const depositRails = response.deposit_rails; + const hasDepositRails = + Array.isArray(depositRails) && depositRails.length > 0; + const depositRailsSummary: AutorampDepositRailsSummary | undefined = + hasDepositRails || response.status === 'Approved' + ? { + ready: response.status === 'Approved' && hasDepositRails, + } + : undefined; + + return { + id: response.id, + customerId: response.customer_id, + walletAddress: + response.wallet_address !== undefined && + response.wallet_address.length > 0 + ? response.wallet_address + : response.recipient_account?.address, + status: response.status, + depositRailsSummary, + }; +} + +/** + * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). + * + * Lives alongside {@link RampsService} and {@link TransakService}. Authentication + * and MoonPay partner headers are handled by the Ramp API; this service only + * attaches the MetaMask user bearer token. + * + * Paths use the neobank-proxy `/neobank` prefix on the on-ramp.api host. + */ +export class NeoBankService { + readonly name: typeof serviceName; + + readonly #messenger: NeoBankServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #policy: ServicePolicy; + + readonly #environment: RampsEnvironment; + + readonly #context: string; + + readonly #baseUrlOverride?: string; + + #walletRegistrationService: WalletRegistrationService | undefined; + + constructor({ + messenger, + environment = RampsEnvironment.Staging, + context, + fetch: fetchFunction, + policyOptions = {}, + baseUrlOverride, + }: { + messenger: NeoBankServiceMessenger; + environment?: RampsEnvironment; + context: string; + fetch: typeof fetch; + policyOptions?: CreateServicePolicyOptions; + baseUrlOverride?: string; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#policy = createServicePolicy({ + retryFilterPolicy: handleWhen(isRetryableError), + ...policyOptions, + }); + this.#environment = environment; + this.#context = context; + this.#baseUrlOverride = baseUrlOverride; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #getBaseUrl(): string { + if (this.#baseUrlOverride) { + return this.#baseUrlOverride; + } + return getBaseUrl(this.#environment); + } + + /** + * Lazily builds the wallet registration client. Deferred so constructing the + * service never resolves the base URL eagerly (an invalid environment only + * throws when a request is made, matching the other neo-bank methods). + * + * @returns The wallet registration client. + */ + #getWalletRegistrationService(): WalletRegistrationService { + this.#walletRegistrationService ??= new WalletRegistrationService({ + fetch: this.#fetch, + baseUrl: this.#getBaseUrl(), + getAuthToken: async (): Promise => + this.#messenger.call('AuthenticationController:getBearerToken'), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), + }); + return this.#walletRegistrationService; + } + + async #getRequestHeaders( + options: NeoBankRequestOptions = {}, + ): Promise> { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + const headers: Record = { + Authorization: `Bearer ${bearerToken}`, + }; + if (options.idempotencyKey) { + headers['Idempotency-Key'] = options.idempotencyKey; + } + return headers; + } + + #buildUrl(path: string, query?: NeoBankQueryParams): URL { + const url = new URL(getNeoBankPath(path), this.#getBaseUrl()); + url.searchParams.set('sdk', RAMPS_SDK_VERSION); + url.searchParams.set('controller', packageJson.version); + url.searchParams.set('context', this.#context); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + } + return url; + } + + /** + * Throws an {@link HttpError} that carries the upstream response body. + * + * The neobank-proxy mirrors MoonPay's status *and* body verbatim, so the + * body is usually the only place that explains a 4xx (e.g. which field or + * permission was rejected). Dropping it makes failures undiagnosable. + * + * @param url - Request URL, for context in the message. + * @param response - Non-OK fetch response. + */ + async #throwHttpError(url: URL, response: Response): Promise { + let detail = ''; + try { + const body = (await response.text()).trim(); + if (body) { + detail = ` - ${body.slice(0, 500)}`; + } + } catch { + // Body already consumed or unreadable; the status alone still helps. + } + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'${detail}`, + ); + } + + async #getJson( + path: string, + query?: NeoBankQueryParams, + ): Promise { + const url = this.#buildUrl(path, query); + return this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(); + const fetchResponse = await this.#fetch(url, { headers }); + if (!fetchResponse.ok) { + await this.#throwHttpError(url, fetchResponse); + } + return fetchResponse.json() as Promise; + }); + } + + async #postJson( + path: string, + body: Record, + options: NeoBankRequestOptions, + ): Promise { + const url = this.#buildUrl(path); + return this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(options); + headers['Content-Type'] = 'application/json'; + const fetchResponse = await this.#fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!fetchResponse.ok) { + await this.#throwHttpError(url, fetchResponse); + } + return fetchResponse.json() as Promise; + }); + } + + #mapAutorampResponse( + response: NeoBankAutorampResponse, + ): AutorampRemoteSnapshot { + if (!response || typeof response !== 'object' || !response.id) { + throw new Error('Malformed response received from neo-bank autoramp API'); + } + return mapNeoBankAutorampToRemoteSnapshot(response); + } + + /** + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ + async getAutoramp(autorampId: string): Promise { + const response = await this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}`, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async registerPixAddress( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson('addresses/pix', body, options); + } + + /** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuote(query: NeoBankQueryParams = {}): Promise { + return this.#getJson('autoramps/quote', query); + } + + /** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ + async createAutoramp( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + const response = await this.#postJson( + 'autoramps', + body, + options, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuoteForAutoramp( + autorampId: string, + query: NeoBankQueryParams = {}, + ): Promise { + return this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}/quote`, + query, + ); + } + + /** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async attachAutorampQuote( + autorampId: string, + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson( + `autoramps/${encodeURIComponent(autorampId)}/quotes`, + body, + options, + ); + } + + /** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ + async getCustomerByExternalId(externalId: string): Promise { + return this.#getJson( + `customers/${encodeURIComponent(externalId)}/external`, + ); + } + + /** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#getWalletRegistrationService().getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + customerId, + address, + }: GetWalletRegistrationStatusParams): Promise { + return await this.#getWalletRegistrationService().getRegistrationStatus({ + customerId, + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#getWalletRegistrationService().registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + + /** + * Resolves the MetaMask canonical profile id used as MoonPay's partner + * `external_id` for neobank customer lookup. + * + * @returns Canonical profile id. + */ + async #getCanonicalExternalId(): Promise { + const profile = await this.#messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', + ); + } + return externalId; + } + + onRetry( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onRetry(listener); + } + + onBreak( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onBreak(listener); + } + + onDegraded( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onDegraded(listener); + } +} diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 3898e4fea14..b0ce940b265 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -280,6 +280,123 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ +export type RampsControllerAddAutorampAction = { + type: `RampsController:addAutoramp`; + handler: RampsController['addAutoramp']; +}; + +/** + * Creates an autoramp via the Ramp API neo-bank proxy and applies the + * returned snapshot locally. + * + * The MoonPay `customer_id` is not accepted from callers: it is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request. This keeps the sensitive customer id owned by Profile Sync / + * the neo-bank proxy and avoids requiring the UI to know or plumb it. + * + * @param request - CreateAutoramp payload (any `customer_id` is overwritten). + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ +export type RampsControllerCreateAutorampAction = { + type: `RampsController:createAutoramp`; + handler: RampsController['createAutoramp']; +}; + +/** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} + * (Profile Sync → neobank-proxy external-id lookup) before the first + * list/lookup because list requires `customer_id` in the path. Message + * construction, EIP-191 signing, submission, and ambiguous-write + * reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type RampsControllerRegisterMoneyAccountWalletAction = { + type: `RampsController:registerMoneyAccountWallet`; + handler: RampsController['registerMoneyAccountWallet']; +}; + +/** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerRemoveAutorampAction = { + type: `RampsController:removeAutoramp`; + handler: RampsController['removeAutoramp']; +}; + +/** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerMarkAutorampAsNotifiedAction = { + type: `RampsController:markAutorampAsNotified`; + handler: RampsController['markAutorampAsNotified']; +}; + +/** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ +export type RampsControllerApplyAutorampStatusFromPushAction = { + type: `RampsController:applyAutorampStatusFromPush`; + handler: RampsController['applyAutorampStatusFromPush']; +}; + +/** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ +export type RampsControllerRefreshAutorampAction = { + type: `RampsController:refreshAutoramp`; + handler: RampsController['refreshAutoramp']; +}; + +/** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ +export type RampsControllerRefreshAutorampsAction = { + type: `RampsController:refreshAutoramps`; + handler: RampsController['refreshAutoramps']; +}; + +/** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ +export type RampsControllerSyncAutorampsWithUserStorageAction = { + type: `RampsController:syncAutorampsWithUserStorage`; + handler: RampsController['syncAutorampsWithUserStorage']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -689,6 +806,15 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerAddAutorampAction + | RampsControllerCreateAutorampAction + | RampsControllerRegisterMoneyAccountWalletAction + | RampsControllerRemoveAutorampAction + | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerApplyAutorampStatusFromPushAction + | RampsControllerRefreshAutorampAction + | RampsControllerRefreshAutorampsAction + | RampsControllerSyncAutorampsWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 54dca251d3c..44a5388eb9c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -11,6 +11,7 @@ import type { Json } from '@metamask/utils'; import * as fs from 'fs'; import * as path from 'path'; +import { AutorampStatus } from './autorampAccount.js'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; import type { RampsControllerMessenger, @@ -22,6 +23,8 @@ import { RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { @@ -63,6 +66,7 @@ import type { TransakOrderPaymentMethod, PatchUserRequestBody, } from './TransakService.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; /** * The default redirect ("fake callback") URL a staging `RampsService` returns. @@ -77,12 +81,12 @@ describe('RampsController', () => { 'Execution prevented because the circuit breaker is open'; describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { - it('includes every RampsService action that RampsController calls', async () => { + it('includes every RampsService, TransakService, and NeoBankService action that RampsController calls', async () => { expect.hasAssertions(); const controllerPath = path.join(__dirname, 'RampsController.ts'); const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu; + /messenger\.call\s*\(\s*['"]((RampsService|TransakService|NeoBankService):[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -103,6 +107,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -179,6 +184,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2198,6 +2204,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2264,6 +2271,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2306,6 +2314,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2324,6 +2333,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -8935,6 +8945,821 @@ describe('RampsController', () => { }); }); + describe('autoramps', () => { + it('adds and removes autoramp accounts', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(controller.state.autoramps[0]?.id).toBe('ar-1'); + expect(controller.state.autoramps[0]?.status).toBe( + AutorampStatus.Authorized, + ); + + controller.removeAutoramp('ar-1'); + expect(controller.state.autoramps).toHaveLength(0); + }); + }); + + it('applies push snapshots and publishes notable transitions', async () => { + await withController(async ({ controller, messenger }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:autorampStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + const updated = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + expect(updated.status).toBe(AutorampStatus.Approved); + expect(updated.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + shouldNotify: true, + }); + }); + }); + + it('refreshes autoramps via NeoBankService', async () => { + await withController(async ({ controller, rootMessenger }) => { + const getAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + getAutoramp, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramp('ar-1'); + expect(getAutoramp).toHaveBeenCalledWith('ar-1'); + expect(updated.status).toBe(AutorampStatus.Approved); + + await controller.refreshAutoramps(); + expect(getAutoramp).toHaveBeenCalledTimes(2); + }); + }); + + it('injects the Profile Sync customer id and applies the created autoramp', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => ({ id: 'cust-99' }), + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-99', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + const created = await controller.createAutoramp( + { customer_id: 'attacker-supplied', foo: 'bar' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(createAutoramp).toHaveBeenCalledWith( + { foo: 'bar', customer_id: 'cust-99' }, + { idempotencyKey: 'idem-1' }, + ); + expect(created.id).toBe('ar-new'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-new')?.customerId, + ).toBe('cust-99'); + }); + }); + + it('prefers canonicalProfileId when resolving the external customer id', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-canonical' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-canonical', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('canonical-1'); + }); + }); + + it('throws when no mapped external customer is available', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => null, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /no MoonPay customer is mapped to external id "profile-1"/u, + ); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('throws when the wallet is not signed in to Profile Sync', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: '', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /wallet is not signed in to Profile Sync/u, + ); + expect(getCustomerByExternalId).not.toHaveBeenCalled(); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('falls back to profileId when canonicalProfileId is empty', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-profile' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + async () => ({ + id: 'ar-new', + customerId: 'cust-profile', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }), + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + }); + }); + + it('skips failed refreshes when refreshing all autoramps', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + async (id: string) => { + if (id === 'ar-bad') { + throw new Error('network'); + } + return { + id, + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }; + }, + ); + + controller.addAutoramp({ + id: 'ar-bad', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + controller.addAutoramp({ + id: 'ar-good', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramps(); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe('ar-good'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-bad')?.status, + ).toBe(AutorampStatus.Authorized); + }); + }); + + it('syncs autoramps with user storage when gates pass', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => + ({ + isBackupAndSyncEnabled: true, + }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + // Allow any incremental push from addAutoramp to settle, then full sync. + await Promise.resolve(); + batchSet.mockClear(); + + await controller.syncAutorampsWithUserStorage(); + + expect(batchSet).toHaveBeenCalled(); + const [, entries] = batchSet.mock.calls[0] as [ + string, + [string, string][], + ]; + expect(entries[0]?.[0]).toBe('ar-1'); + expect(JSON.parse(entries[0]?.[1] ?? '{}').o.id).toBe('ar-1'); + }); + }); + + it('marks autoramp as notified', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + expect(controller.state.autoramps[0]?.notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); + + /** + * Registers the User Storage / auth handlers that let the incremental + * autoramp pushes run, so tests can drive the remote-write code paths. + * + * @param rootMessenger - Root messenger of the controller under test. + * @param batchSet - Handler for `performBatchSetStorage`. + */ + function registerAutorampSyncHandlers( + rootMessenger: RootMessenger, + batchSet: jest.Mock, + ): void { + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => ({ isBackupAndSyncEnabled: true }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + } + + /** + * Lets floating remote-push promises settle. + */ + async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it('updates an existing autoramp when the id is already known', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Approved, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(updated.walletAddress).toBe('0xdef'); + expect(updated.status).toBe(AutorampStatus.Approved); + }); + }); + + it('ignores removal and notification for unknown autoramp ids', async () => { + await withController(({ controller }) => { + controller.removeAutoramp('missing'); + controller.markAutorampAsNotified('missing'); + + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('queues a remote delete when a full sync holds the semaphore', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + controller.setIsAutorampSyncingInProgress(true); + controller.removeAutoramp('ar-1'); + + const pending = controller.getPendingRemoteAutorampDeletes(); + expect(pending.map((account) => account.id)).toStrictEqual(['ar-1']); + + controller.acknowledgePendingRemoteAutorampDeletes([]); + expect(controller.getPendingRemoteAutorampDeletes()).toHaveLength(1); + + controller.acknowledgePendingRemoteAutorampDeletes(pending); + expect(controller.getPendingRemoteAutorampDeletes()).toStrictEqual([]); + + controller.setIsAutorampSyncingInProgress(false); + }); + }); + + it('suppresses remote pushes while applying sync changes locally', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.setIsApplyingAutorampSyncChanges(true); + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + controller.removeAutoramp('ar-1'); + controller.setIsApplyingAutorampSyncChanges(false); + + await flushPromises(); + + expect(batchSet).not.toHaveBeenCalled(); + }); + }); + + it('swallows remote storage failures raised by autoramp mutations', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockRejectedValue(new Error('storage down')); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + await flushPromises(); + + controller.markAutorampAsNotified('ar-1'); + await flushPromises(); + + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + await flushPromises(); + + controller.removeAutoramp('ar-1'); + await flushPromises(); + + expect(batchSet).toHaveBeenCalled(); + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('creates an autoramp from a push that carries no wallet address', async () => { + await withController(({ controller }) => { + const created = controller.applyAutorampStatusFromPush({ + id: 'ar-new', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(created.walletAddress).toBe(''); + expect(controller.state.autoramps).toHaveLength(1); + }); + }); + + it('keeps local identity fields when a remote push omits or blanks them', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const afterOmitted = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + status: AutorampStatus.Approved, + }); + + expect(afterOmitted.customerId).toBe('cust-1'); + expect(afterOmitted.walletAddress).toBe('0xabc'); + + const afterBlank = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + walletAddress: '', + status: AutorampStatus.Approved, + }); + + expect(afterBlank.customerId).toBe('cust-1'); + expect(afterBlank.walletAddress).toBe('0xabc'); + }); + }); + }); + + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + type WalletRegistrationHandlers = { + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; + }; + + /** + * Registers default handlers for every messenger action the wallet + * registration flow calls, returning the mocks for per-test overrides. + * + * @param rootMessenger - The root messenger of the controller under test. + * @returns The registered handler mocks. + */ + function registerWalletRegistrationHandlers( + rootMessenger: RootMessenger, + ): WalletRegistrationHandlers { + const handlers: WalletRegistrationHandlers = { + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }), + getCustomerByExternalId: jest + .fn() + .mockResolvedValue({ id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), + }; + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); + return handlers; + } + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('signs and submits an ownership proof for an absent registration', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer iron-customer-1.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'iron-customer-1', + signature: '0xsig', + idempotencyKey: expect.any(String), + }), + ); + }); + }); + + it('resolves the customer id via Profile Sync external-id lookup', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }); + handlers.getCustomerByExternalId.mockResolvedValue({ + id: 'iron-customer-fallback', + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getCustomerByExternalId).toHaveBeenCalledWith( + 'canonical-1', + ); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows an initial lookup failure without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new Error('lookup failed'); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + describe('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', @@ -11835,6 +12660,8 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, + ...RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, 'RemoteFeatureFlagController:getState', ], }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index aac160d8825..fe7e0fbc5e1 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,19 +6,50 @@ import type { import { BaseController } from '@metamask/base-controller'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { UserStorageController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; +import { + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal, + updateAutorampInRemoteStorage, +} from './autoramp-syncing/index.js'; +import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; +import type { + AutorampSyncingController, + AutorampSyncingOptions, +} from './autoramp-syncing/types.js'; +import type { + AutorampAccount, + AutorampRemoteSnapshot, + CreateAutorampRequest, +} from './autorampAccount.js'; +import { + applyAutorampRemoteStatus, + createAutorampAccount, + markAutorampNotified, +} from './autorampAccount.js'; import { getHeadlessProviderAllowlist, isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, +} from './NeoBankService-method-action-types.js'; +import type { NeoBankServiceActions } from './NeoBankService.js'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js'; +import { buildOwnershipMessage } from './ownership-message.js'; import { getProvidersServingAsset, providerServesAsset, @@ -116,6 +147,18 @@ import type { TransakOrder, } from './TransakService.js'; import type { TransakServiceActions } from './TransakService.js'; +import { + createInitialState as createInitialWalletRegistrationState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; // === GENERAL === @@ -131,10 +174,7 @@ export const controllerName = 'RampsController'; * Any host (e.g. mobile) that creates a RampsController messenger must delegate * these actions from the root messenger so the controller can function. */ -export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( - | RampsServiceActions['type'] - | TransakServiceActions['type'] -)[] = [ +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', 'RampsService:getCountries', @@ -170,7 +210,65 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelOrder', 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', -]; + 'NeoBankService:getAutoramp', + 'NeoBankService:createAutoramp', + 'NeoBankService:getCustomerByExternalId', + 'NeoBankService:getWalletRegistrationStatus', + 'NeoBankService:registerSelfHostedWallet', +] as const satisfies readonly ( + | RampsServiceActions['type'] + | TransakServiceActions['type'] + | NeoBankServiceActions['type'] +)[]; + +/** + * Other controller actions RampsController calls via the messenger. + * Hosts that enable autoramp creation must delegate these from the root + * messenger so the controller can resolve the vendor customer identity via + * Profile Sync (`AuthenticationController:getSessionProfile`) and the + * neo-bank external-id lookup. `KeyringController:signPersonalMessage` is + * required for Money Account self-hosted wallet registration (EIP-191 + * ownership proof). + */ +export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ + 'AuthenticationController:getSessionProfile', + 'KeyringController:signPersonalMessage', +] as const; + +/** + * Structural type for the keyring controller's `signPersonalMessage` messenger + * action (EIP-191). Declared locally (mirroring + * `@metamask/keyring-controller`) to avoid a package dependency for a single + * type-only messenger action. + */ +export type KeyringControllerSignPersonalMessageAction = { + type: 'KeyringController:signPersonalMessage'; + handler: (messageParams: { data: string; from: string }) => Promise; +}; + +/** + * Successful outcome of {@link RampsController.registerMoneyAccountWallet}. + */ +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + +/** + * User Storage / auth actions needed for autoramp Backup & Sync. + * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. + */ +export const RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS = [ + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', +] as const; /** * Default TTL for quotes requests (15 seconds). @@ -218,6 +316,22 @@ function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus { ); } +/** + * Distinguishes an already-materialized {@link AutorampAccount} from the + * create-fields shape accepted by {@link RampsController.addAutoramp}. + * + * @param value - Full account or create fields. + * @returns Whether the value already carries the derived account fields. + */ +function isFullAutorampAccount( + value: AutorampAccount | { id: string; customerId: string }, +): value is AutorampAccount { + return ( + typeof (value as AutorampAccount).updatedAt === 'number' && + (value as AutorampAccount).lastSeenStatus !== undefined + ); +} + function getRampsErrorInfo(error: unknown): RampsErrorInfo { if (error instanceof BrokenCircuitError && hasStringMessage(error)) { return { @@ -387,6 +501,12 @@ export type RampsControllerState = { * and persists them. */ orders: RampsOrder[]; + /** + * MoonPay Enterprise autoramp accounts (standing routes), separate from + * {@link RampsOrder} payment instances. Refreshed from remote on load / + * push; persisted for rediscovery and transition UX. + */ + autoramps: AutorampAccount[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -448,6 +568,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + autoramps: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -514,6 +640,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, }, orders: [], + autoramps: [], providerAutoSelected: false, }; } @@ -638,7 +765,18 @@ type AllowedActions = | TransakServiceGetIdProofStatusAction | TransakServiceCancelOrderAction | TransakServiceCancelAllActiveOrdersAction - | TransakServiceGetActiveOrdersAction; + | TransakServiceGetActiveOrdersAction + | NeoBankServiceGetAutorampAction + | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction + | KeyringControllerSignPersonalMessageAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | AuthenticationController.AuthenticationControllerIsSignedInAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; /** * Published when the state of {@link RampsController} changes. @@ -657,12 +795,28 @@ export type RampsControllerOrderStatusChangedEvent = { payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }]; }; +/** + * Published when an autoramp account status transitions to a notable state + * that the UI has not yet notified for (e.g. Approved / Rejected). + */ +export type RampsControllerAutorampStatusChangedEvent = { + type: `${typeof controllerName}:autorampStatusChanged`; + payload: [ + { + autoramp: AutorampAccount; + previousStatus: AutorampAccount['status']; + shouldNotify: boolean; + }, + ]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ export type RampsControllerEvents = | RampsControllerStateChangeEvent - | RampsControllerOrderStatusChangedEvent; + | RampsControllerOrderStatusChangedEvent + | RampsControllerAutorampStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -811,6 +965,15 @@ const MESSENGER_EXPOSED_METHODS = [ 'getQuotes', 'addOrder', 'removeOrder', + 'addAutoramp', + 'createAutoramp', + 'removeAutoramp', + 'registerMoneyAccountWallet', + 'markAutorampAsNotified', + 'applyAutorampStatusFromPush', + 'refreshAutoramp', + 'refreshAutoramps', + 'syncAutorampsWithUserStorage', 'startOrderPolling', 'stopOrderPolling', 'getBuyWidgetData', @@ -890,6 +1053,12 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + #isAutorampSyncingInProgress = false; + + #isApplyingAutorampSyncChanges = false; + + #pendingRemoteAutorampDeletes: AutorampAccount[] = []; + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -2437,6 +2606,541 @@ export class RampsController extends BaseController< this.#orderPollingMeta.delete(providerOrderId); } + // === AUTORAMP ACCOUNT MANAGEMENT === + + /** + * Whether a full autoramp User Storage sync is currently running. + * + * @returns True when a full autoramp sync is in progress. + */ + get isAutorampSyncingInProgress(): boolean { + return this.#isAutorampSyncingInProgress; + } + + /** + * Sets the autoramp sync semaphore (used by autoramp-syncing module). + * + * @param value - Whether sync is in progress. + */ + setIsAutorampSyncingInProgress(value: boolean): void { + this.#isAutorampSyncingInProgress = value; + } + + /** + * Sets whether local mutations are applying remote sync results + * (suppresses incremental remote pushes). + * + * @param value - Whether sync changes are being applied locally. + */ + setIsApplyingAutorampSyncChanges(value: boolean): void { + this.#isApplyingAutorampSyncChanges = value; + } + + /** + * Returns autoramps deleted locally while a full sync held the semaphore. + * + * @returns Pending remote delete queue. + */ + getPendingRemoteAutorampDeletes(): AutorampAccount[] { + return [...this.#pendingRemoteAutorampDeletes]; + } + + /** + * Clears acknowledged pending remote deletes after tombstones are written. + * + * @param accounts - Accounts whose remote tombstones were persisted. + */ + acknowledgePendingRemoteAutorampDeletes(accounts: AutorampAccount[]): void { + if (accounts.length === 0) { + return; + } + const keys = new Set(accounts.map((account) => account.id)); + this.#pendingRemoteAutorampDeletes = + this.#pendingRemoteAutorampDeletes.filter( + (account) => !keys.has(account.id), + ); + } + + #getAutorampSyncingOptions(): AutorampSyncingOptions { + return { + getRampsControllerInstance: (): AutorampSyncingController => this, + getMessenger: (): RampsControllerMessenger => this.messenger, + }; + } + + /** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ + addAutoramp( + accountOrInput: + | AutorampAccount + | { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampAccount['status'] | string; + }, + ): AutorampAccount { + const account: AutorampAccount = isFullAutorampAccount(accountOrInput) + ? accountOrInput + : createAutorampAccount(accountOrInput); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (existing) => existing.id === account.id, + ); + if (idx === -1) { + state.autoramps.push(account as Draft); + } else { + state.autoramps[idx] = { + ...state.autoramps[idx], + ...account, + } as Draft; + } + }); + + const upserted = + this.state.autoramps.find((existing) => existing.id === account.id) ?? + account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + + /** + * Creates an autoramp via the Ramp API neo-bank proxy and applies the + * returned snapshot locally. + * + * The MoonPay `customer_id` is not accepted from callers: it is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request. This keeps the sensitive customer id owned by Profile Sync / + * the neo-bank proxy and avoids requiring the UI to know or plumb it. + * + * @param request - CreateAutoramp payload (any `customer_id` is overwritten). + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ + async createAutoramp( + request: CreateAutorampRequest, + options: { idempotencyKey?: string } = {}, + ): Promise { + const customerId = await this.resolveAutorampCustomerId(); + + const body = { ...request, customer_id: customerId }; + const remote = await this.messenger.call( + 'NeoBankService:createAutoramp', + body, + options, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Resolves the MoonPay `customer_id` for autoramp operations. + * + * Maps the wallet's Profile Sync id (the partner `external_id`) to the + * MoonPay customer via the neo-bank proxy's + * `GET /neobank/customers/{external_id}/external`. Prefers + * `canonicalProfileId` when present, otherwise `profileId`, matching + * {@link NeoBankService}'s canonical external-id resolution. + * + * @returns The MoonPay customer id. + */ + async resolveAutorampCustomerId(): Promise { + const profile = await this.messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Cannot create autoramp: wallet is not signed in to Profile Sync.', + ); + } + + const customer = await this.messenger.call( + 'NeoBankService:getCustomerByExternalId', + externalId, + ); + const customerId = + customer && + typeof customer === 'object' && + typeof (customer as { id?: unknown }).id === 'string' + ? (customer as { id: string }).id + : null; + if (!customerId) { + throw new Error( + `Cannot create autoramp: no MoonPay customer is mapped to external id "${externalId}".`, + ); + } + return customerId; + } + + /** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} + * (Profile Sync → neobank-proxy external-id lookup) before the first + * list/lookup because list requires `customer_id` in the path. Message + * construction, EIP-191 signing, submission, and ambiguous-write + * reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: string; + }): Promise { + let machine = transitionWalletRegistration( + createInitialWalletRegistrationState(), + { type: 'START' }, + ); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + // List requires customer_id in the neobank path, so resolve Iron's id + // before the first lookup. + const customerId = await this.resolveAutorampCustomerId(); + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + // Stable across transient retries of the same ownership proof; refreshed + // when the UTC-dated message must be rebuilt and re-signed. + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'NeoBankService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + idempotencyKey, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + + /** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ + removeAutoramp(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + + this.update((state) => { + state.autoramps = state.autoramps.filter( + (autoramp) => autoramp.id !== autorampId, + ); + }); + + if (!existing || this.#isApplyingAutorampSyncChanges) { + return; + } + + if (this.#isAutorampSyncingInProgress) { + this.#pendingRemoteAutorampDeletes.push(existing); + return; + } + + deleteAutorampInRemoteStorage( + existing, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + /** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ + markAutorampAsNotified(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + if (!existing) { + return; + } + const notified = markAutorampNotified(existing); + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === autorampId, + ); + if (idx !== -1) { + state.autoramps[idx] = notified as Draft; + } + }); + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + notified, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + } + + /** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ + applyAutorampStatusFromPush(remote: AutorampRemoteSnapshot): AutorampAccount { + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ + async refreshAutoramp(autorampId: string): Promise { + const remote = await this.messenger.call( + 'NeoBankService:getAutoramp', + autorampId, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ + async refreshAutoramps(): Promise { + const ids = this.state.autoramps.map((autoramp) => autoramp.id); + const updated: AutorampAccount[] = []; + + for (const id of ids) { + try { + updated.push(await this.refreshAutoramp(id)); + } catch { + // Keep local state for this id; continue remaining refreshes. + } + } + + return updated; + } + + /** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ + async syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig = {}, + ): Promise { + await syncAutorampsWithUserStorageInternal( + config, + this.#getAutorampSyncingOptions(), + ); + } + + #applyAutorampRemoteSnapshot( + remote: AutorampRemoteSnapshot, + ): AutorampAccount { + const local = + this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? + null; + const result = applyAutorampRemoteStatus(local, remote); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === result.account.id, + ); + if (idx === -1) { + state.autoramps.push(result.account as Draft); + } else { + state.autoramps[idx] = result.account as Draft; + } + }); + + if (result.statusChanged) { + this.messenger.publish('RampsController:autorampStatusChanged', { + autoramp: result.account, + previousStatus: result.previousStatus, + shouldNotify: result.shouldNotify, + }); + } + + const upserted = + this.state.autoramps.find( + (autoramp) => autoramp.id === result.account.id, + ) ?? result.account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + /** * Refreshes a single order via the V2 API and updates it in state. * Publishes orderStatusChanged if the status transitioned. diff --git a/packages/ramps-controller/src/autoramp-syncing/constants.ts b/packages/ramps-controller/src/autoramp-syncing/constants.ts new file mode 100644 index 00000000000..c69c57ea629 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/constants.ts @@ -0,0 +1,25 @@ +/** + * User Storage feature key for MoonPay Enterprise autoramp accounts. + * Each autoramp is stored as a separate entry under this feature. + */ +export const USER_STORAGE_RAMPS_AUTORAMPS_FEATURE = 'rampsAutoramps'; + +/** + * Key for version in User Storage schema. + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of the autoramp User Storage schema. + */ +export const USER_STORAGE_VERSION = '1'; + +/** + * Trace names for autoramp syncing operations. + */ +export const TraceName = { + AutorampSyncFull: 'Ramps Autoramp Sync Full', + AutorampSyncSaveBatch: 'Ramps Autoramp Sync Save Batch', + AutorampSyncUpdateRemote: 'Ramps Autoramp Sync Update Remote', + AutorampSyncDeleteRemote: 'Ramps Autoramp Sync Delete Remote', +} as const; diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts new file mode 100644 index 00000000000..0d8195cf10c --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts @@ -0,0 +1,690 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + computeAutorampMergePlan, + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, +} from './controller-integration.js'; +import { mapAutorampToUserStorageEntry } from './format-utils.js'; +import type { + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampAccount, +} from './types.js'; + +/** + * Builds an autoramp account with sync-relevant defaults. + * + * @param overrides - Fields to override on the generated account. + * @returns A sync-aware autoramp account. + */ +function buildAccount( + overrides: Partial & { id: string }, +): SyncAutorampAccount { + return { + ...createAutorampAccount({ + customerId: 'customer-1', + walletAddress: '0xwallet', + status: AutorampStatus.Authorized, + updatedAt: 1_000, + ...overrides, + }), + ...(overrides.deletedAt === undefined + ? {} + : { deletedAt: overrides.deletedAt }), + }; +} + +/** + * Serializes an account the way User Storage would return it. + * + * @param account - Account to serialize. + * @returns JSON string of the remote entry. + */ +function toRemoteEntryJson(account: SyncAutorampAccount): string { + return JSON.stringify(mapAutorampToUserStorageEntry(account)); +} + +type Harness = { + options: AutorampSyncingOptions; + controller: jest.Mocked & { + state: { autoramps: AutorampAccount[] }; + }; + call: jest.Mock; + onAutorampSyncErroneousSituation: jest.Mock; + batchSetCalls: () => [string, string][][]; +}; + +/** + * Builds a sync test harness with a stubbed controller and messenger. + * + * @param args - Harness configuration. + * @param args.localAccounts - Accounts present in controller state. + * @param args.remoteEntries - Raw JSON entries returned by User Storage. + * @param args.pendingDeletes - Accounts queued for remote soft-delete. + * @param args.canSync - Whether the Backup & Sync gates should pass. + * @param args.trace - Optional trace callback. + * @returns The harness. + */ +function buildHarness({ + localAccounts = [], + remoteEntries = [], + pendingDeletes = [], + canSync = true, + trace, +}: { + localAccounts?: AutorampAccount[]; + remoteEntries?: (string | null)[]; + pendingDeletes?: AutorampAccount[]; + canSync?: boolean; + trace?: AutorampSyncingOptions['trace']; +} = {}): Harness { + const batchSetCalls: [string, string][][] = []; + + const call = jest.fn((action: string, ...args: unknown[]) => { + switch (action) { + case 'UserStorageController:getState': + return { isBackupAndSyncEnabled: canSync }; + case 'AuthenticationController:isSignedIn': + return canSync; + case 'UserStorageController:performGetStorageAllFeatureEntries': + return remoteEntries; + case 'UserStorageController:performBatchSetStorage': + batchSetCalls.push(args[1] as [string, string][]); + return undefined; + default: + throw new Error(`unexpected action ${action}`); + } + }); + + const state = { autoramps: [...localAccounts] }; + + const controller = { + state, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn((account: AutorampAccount) => { + const index = state.autoramps.findIndex( + (entry) => entry.id === account.id, + ); + if (index === -1) { + state.autoramps.push(account); + } else { + state.autoramps[index] = account; + } + return account; + }), + removeAutoramp: jest.fn((autorampId: string) => { + state.autoramps = state.autoramps.filter( + (entry) => entry.id !== autorampId, + ); + controller.state.autoramps = state.autoramps; + }), + getPendingRemoteAutorampDeletes: jest.fn(() => pendingDeletes), + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + } as unknown as Harness['controller']; + + const onAutorampSyncErroneousSituation = jest.fn(); + + return { + options: { + getRampsControllerInstance: () => controller, + getMessenger: () => ({ call }) as never, + ...(trace ? { trace } : {}), + }, + controller, + call, + onAutorampSyncErroneousSituation, + batchSetCalls: () => batchSetCalls, + }; +} + +describe('computeAutorampMergePlan', () => { + it('ignores remote tombstones for accounts that are absent locally', () => { + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([], [remote]); + + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('re-uploads a local account that is newer than a remote tombstone', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 9_000 }); + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToUpdateRemotely.map((account) => account.id), + ).toStrictEqual(['ar-1']); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + }); + + it('treats a local account with no timestamp as older than a tombstone', () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: undefined, + } as unknown as SyncAutorampAccount; + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToDeleteLocally.map((account) => account.id), + ).toStrictEqual(['ar-1']); + }); + + it('imports the remote account when it is newer than the local copy', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 2_000, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToAddOrUpdateLocally.map((a) => a.status), + ).toStrictEqual([AutorampStatus.Approved]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('plans no work when local and remote accounts match', () => { + const local = buildAccount({ id: 'ar-1' }); + const remote = buildAccount({ id: 'ar-1' }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + expect([...plan.remoteAccountsMap.keys()]).toStrictEqual(['ar-1']); + }); +}); + +describe('syncAutorampsWithUserStorage', () => { + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).not.toHaveBeenCalled(); + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('returns early when User Storage holds no entries', async () => { + const harness = buildHarness({ remoteEntries: [] }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.batchSetCalls()).toStrictEqual([]); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenCalledWith(false); + }); + + it('treats a null feature-entries response as empty', async () => { + const harness = buildHarness(); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if ( + action === 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return null; + } + throw new Error(`unexpected action ${action}`); + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('imports remote-only accounts into controller state', async () => { + const remote = buildAccount({ id: 'ar-remote', updatedAt: 2_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(remote)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ar-remote' }), + ); + expect( + harness.controller.setIsApplyingAutorampSyncChanges.mock.calls, + ).toStrictEqual([[true], [false]]); + }); + + it('uploads local-only accounts to User Storage', async () => { + const local = buildAccount({ id: 'ar-local', updatedAt: 3_000 }); + const other = buildAccount({ id: 'ar-other', updatedAt: 4_000 }); + const harness = buildHarness({ + localAccounts: [local, other], + remoteEntries: [toRemoteEntryJson(other)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-local']); + }); + + it('stamps an upload that has no local timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-local' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.call).toHaveBeenCalledWith( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + expect.any(Array), + ); + const [entries] = harness.batchSetCalls(); + const uploaded = entries.find(([key]) => key === 'ar-local'); + expect(uploaded).toBeDefined(); + expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan(0); + }); + + it('deletes local accounts that were tombstoned remotely', async () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const tombstone = buildAccount({ + id: 'ar-1', + updatedAt: 5_000, + deletedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(tombstone)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.removeAutoramp).toHaveBeenCalledWith('ar-1'); + }); + + it('does not re-import a remote account that is queued for local deletion', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(pending)], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('uploads tombstones for pending remote deletes and acknowledges them', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + const tombstone = entries.find(([key]) => key === 'ar-pending'); + expect(tombstone).toBeDefined(); + expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan( + 0, + ); + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).toHaveBeenCalledWith([pending]); + }); + + it('ignores pending deletes that have no storage key', async () => { + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [ + { ...buildAccount({ id: 'ar-pending' }), id: '' } as AutorampAccount, + ], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).not.toHaveBeenCalled(); + }); + + it('re-uploads a local account whose newer remote copy was not imported', async () => { + // The account is queued for deletion, so the newer remote copy is not + // applied locally; the surviving local copy still has to reach the remote. + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + expect(JSON.parse(entries[0][1]).o.status).toBe(AutorampStatus.Authorized); + }); + + it('stamps a re-uploaded local account that has no timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).lu).toBeGreaterThan(0); + }); + + it('reports an unsupported storage version and skips the entry', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: '999', + o: { id: 'ar-1' }, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Unsupported autoramp storage version', + { version: '999', expectedVersion: USER_STORAGE_VERSION }, + ); + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('reports a remote entry that is missing its payload', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Remote autoramp entry missing payload', + {}, + ); + }); + + it('reports a remote entry that cannot be parsed', async () => { + const harness = buildHarness({ remoteEntries: ['not json'] }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to parse remote autoramp entry', + expect.objectContaining({ entryLength: 'not json'.length }), + ); + }); + + it('skips a remote entry whose payload has no id', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: '', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + }, + lu: 1_000, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + expect(harness.onAutorampSyncErroneousSituation).not.toHaveBeenCalled(); + }); + + it('skips a remote write whose account has an empty storage key', async () => { + const harness = buildHarness({ + localAccounts: [ + { ...buildAccount({ id: 'ar-local' }), id: '' } as AutorampAccount, + ], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + }); + harness.controller.getPendingRemoteAutorampDeletes.mockReturnValue([]); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('reports and rethrows when the sync fails', async () => { + const harness = buildHarness(); + const failure = new Error('storage down'); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw failure; + }); + + await expect( + syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ), + ).rejects.toThrow('storage down'); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Error synchronizing autoramps', + { error: failure }, + ); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenLastCalledWith(false); + }); + + it('wraps the sync and the batch save in traces when a callback is given', async () => { + const traceNames: string[] = []; + const trace = jest.fn( + async (request: { name: string }, fn?: () => unknown) => { + traceNames.push(request.name); + return await (fn as () => Promise)(); + }, + ) as unknown as AutorampSyncingOptions['trace']; + + const harness = buildHarness({ + localAccounts: [buildAccount({ id: 'ar-local' })], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + trace, + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(traceNames).toStrictEqual([ + 'Ramps Autoramp Sync Full', + 'Ramps Autoramp Sync Save Batch', + ]); + }); +}); + +describe('updateAutorampInRemoteStorage', () => { + it('writes the account with a refreshed timestamp', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account that is not syncable', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); + +describe('deleteAutorampInRemoteStorage', () => { + it('writes a tombstone for the account', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).dt).toBeGreaterThan(0); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account with no id', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the tombstone write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts new file mode 100644 index 00000000000..e6c03c52be4 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -0,0 +1,437 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { + AutorampSyncingOptions, + SyncAutorampAccount, + SyncAutorampsWithUserStorageConfig, + UserStorageAutorampEntry, +} from './types.js'; + +function getAutorampTimestamp(account: SyncAutorampAccount): number { + return account.updatedAt ?? 0; +} + +/** + * Builds the local/remote merge plan for autoramp sync. + * + * @param localAccounts - Syncable local accounts. + * @param validRemoteAccounts - Syncable remote accounts. + * @returns Local mutations and remote uploads to apply. + */ +export function computeAutorampMergePlan( + localAccounts: SyncAutorampAccount[], + validRemoteAccounts: SyncAutorampAccount[], +): { + remoteAccountsMap: Map; + accountsToAddOrUpdateLocally: SyncAutorampAccount[]; + accountsToDeleteLocally: SyncAutorampAccount[]; + accountsToUpdateRemotely: SyncAutorampAccount[]; +} { + const localAccountsMap = new Map(); + const remoteAccountsMap = new Map(); + + localAccounts.forEach((account) => { + localAccountsMap.set(createAutorampStorageKey(account), account); + }); + validRemoteAccounts.forEach((account) => { + remoteAccountsMap.set(createAutorampStorageKey(account), account); + }); + + const accountsToAddOrUpdateLocally: SyncAutorampAccount[] = []; + const accountsToDeleteLocally: SyncAutorampAccount[] = []; + const accountsToUpdateRemotely: SyncAutorampAccount[] = []; + + for (const remoteAccount of validRemoteAccounts) { + const key = createAutorampStorageKey(remoteAccount); + const localAccount = localAccountsMap.get(key); + + if (remoteAccount.deletedAt) { + if (localAccount) { + const localTimestamp = getAutorampTimestamp(localAccount); + if (localTimestamp > remoteAccount.deletedAt) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToDeleteLocally.push(remoteAccount); + } + } + } else if (!localAccount) { + accountsToAddOrUpdateLocally.push(remoteAccount); + } else if (!areAutorampsEqual(localAccount, remoteAccount)) { + const localTimestamp = getAutorampTimestamp(localAccount); + const remoteTimestamp = getAutorampTimestamp(remoteAccount); + if (localTimestamp >= remoteTimestamp) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToAddOrUpdateLocally.push(remoteAccount); + } + } + } + + for (const localAccount of localAccounts) { + const key = createAutorampStorageKey(localAccount); + if (!remoteAccountsMap.has(key)) { + accountsToUpdateRemotely.push(localAccount); + } + } + + return { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + }; +} + +async function getRemoteAutoramps( + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig, +): Promise { + const { getMessenger } = options; + const { onAutorampSyncErroneousSituation } = config; + + const remoteJsonArray = + (await getMessenger().call( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + )) ?? []; + + if (remoteJsonArray.length === 0) { + return []; + } + + const remoteAccounts: SyncAutorampAccount[] = []; + for (const entryJson of remoteJsonArray) { + try { + const entry = JSON.parse(entryJson) as UserStorageAutorampEntry; + if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) { + onAutorampSyncErroneousSituation?.( + 'Unsupported autoramp storage version', + { + version: entry[USER_STORAGE_VERSION_KEY], + expectedVersion: USER_STORAGE_VERSION, + }, + ); + continue; + } + if (!entry.o || typeof entry.o !== 'object') { + onAutorampSyncErroneousSituation?.( + 'Remote autoramp entry missing payload', + {}, + ); + continue; + } + const mapped = mapUserStorageEntryToAutoramp(entry); + if (!createAutorampStorageKey(mapped)) { + continue; + } + remoteAccounts.push(mapped); + } catch (error) { + onAutorampSyncErroneousSituation?.( + 'Failed to parse remote autoramp entry', + { error, entryLength: entryJson.length }, + ); + } + } + + return remoteAccounts; +} + +async function saveAutorampsToUserStorage( + accounts: SyncAutorampAccount[], + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig, +): Promise { + const { getMessenger, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + const save = async (): Promise => { + const storageEntries: [string, string][] = []; + for (const account of accounts) { + const key = createAutorampStorageKey(account); + // Defensive: every caller filters on `isSyncableAutoramp` or a non-empty + // key before reaching here, so an id-less account is unreachable today. + /* istanbul ignore next */ + if (!key) { + onAutorampSyncErroneousSituation?.( + 'Skipping autoramp remote write with empty storage key', + { hasId: Boolean(account.id) }, + ); + continue; + } + storageEntries.push([ + key, + JSON.stringify(mapAutorampToUserStorageEntry(account)), + ]); + } + // Defensive: only reachable if every account was skipped above, which the + // callers' filtering already rules out. + /* istanbul ignore next */ + if (storageEntries.length === 0) { + return; + } + await getMessenger().call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + storageEntries, + ); + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncSaveBatch, + data: { autorampCount: accounts.length }, + }, + save, + ); + return; + } + await save(); +} + +/** + * Syncs autoramp accounts between local controller state and User Storage. + * + * @param config - Optional error callbacks. + * @param options - Sync options (controller + messenger). + */ +export async function syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig, + options: AutorampSyncingOptions, +): Promise { + const { getRampsControllerInstance, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + if (!canPerformAutorampSyncing(options)) { + return; + } + + const controller = getRampsControllerInstance(); + controller.setIsAutorampSyncingInProgress(true); + + try { + const validRemoteAccounts = ( + await getRemoteAutoramps(options, config) + ).filter( + (account: SyncAutorampAccount) => + Boolean(account.deletedAt) || isSyncableAutoramp(account), + ); + + const performSync = async (): Promise => { + const getLocalAccounts = (): AutorampAccount[] => + controller.state.autoramps.filter(isSyncableAutoramp); + + const pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteAutorampDeletes() + .map((account) => createAutorampStorageKey(account)) + .filter((key) => key.length > 0), + ); + + const { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + } = computeAutorampMergePlan(getLocalAccounts(), validRemoteAccounts); + + controller.setIsApplyingAutorampSyncChanges(true); + try { + for (const account of accountsToDeleteLocally) { + controller.removeAutoramp(createAutorampStorageKey(account)); + } + for (const account of accountsToAddOrUpdateLocally) { + if ( + !account.deletedAt && + !pendingDeleteKeysBeforeApply.has(createAutorampStorageKey(account)) + ) { + controller.addAutoramp(stripAutorampSyncMetadata(account)); + } + } + } finally { + controller.setIsApplyingAutorampSyncChanges(false); + } + + const localKeys = new Set( + getLocalAccounts().map((account) => createAutorampStorageKey(account)), + ); + const pendingDeletes = controller + .getPendingRemoteAutorampDeletes() + .filter((account) => { + const key = createAutorampStorageKey(account); + return key.length > 0 && !localKeys.has(key); + }); + const pendingDeleteKeys = new Set( + pendingDeletes.map((account) => createAutorampStorageKey(account)), + ); + + const now = Date.now(); + const uploads: SyncAutorampAccount[] = [ + ...accountsToUpdateRemotely + .filter( + (account) => + !pendingDeleteKeys.has(createAutorampStorageKey(account)), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + // Local-only accounts already included via merge plan; also upload + // accounts present locally that differ after apply. + ...getLocalAccounts() + .filter((account) => { + const key = createAutorampStorageKey(account); + // Defensive: `pendingDeletes` already excludes anything still + // present locally, so this cannot match a local account. + /* istanbul ignore next */ + if (pendingDeleteKeys.has(key)) { + return false; + } + const remote = remoteAccountsMap.get(key); + return !remote || !areAutorampsEqual(account, remote); + }) + .filter( + (account) => + !accountsToUpdateRemotely.some( + (planned) => + createAutorampStorageKey(planned) === + createAutorampStorageKey(account), + ), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + ...pendingDeletes.map((account) => ({ + ...account, + deletedAt: now, + updatedAt: now, + })), + ]; + + // Dedupe by key, prefer later entries + const uploadMap = new Map(); + for (const account of uploads) { + uploadMap.set(createAutorampStorageKey(account), account); + } + + if (uploadMap.size > 0) { + await saveAutorampsToUserStorage( + [...uploadMap.values()], + options, + config, + ); + controller.acknowledgePendingRemoteAutorampDeletes(pendingDeletes); + } + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncFull, + data: { + localAutorampCount: + controller.state.autoramps.filter(isSyncableAutoramp).length, + remoteAutorampCount: validRemoteAccounts.length, + }, + }, + performSync, + ); + return; + } + + await performSync(); + } catch (error) { + onAutorampSyncErroneousSituation?.('Error synchronizing autoramps', { + error, + }); + throw error; + } finally { + controller.setIsAutorampSyncingInProgress(false); + } +} + +/** + * Updates a single autoramp in remote storage without a full sync. + * + * @param account - Local autoramp that changed. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function updateAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const update = async (): Promise => { + if (!canPerformAutorampSyncing(options) || !isSyncableAutoramp(account)) { + return; + } + await saveAutorampsToUserStorage( + [{ ...account, updatedAt: Date.now() }], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncUpdateRemote }, update); + return; + } + await update(); +} + +/** + * Soft-deletes an autoramp in remote storage. + * + * @param account - Autoramp to tombstone remotely. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function deleteAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const remove = async (): Promise => { + if (!canPerformAutorampSyncing(options) || !account.id) { + return; + } + const now = Date.now(); + await saveAutorampsToUserStorage( + [ + { + ...account, + deletedAt: now, + updatedAt: now, + }, + ], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncDeleteRemote }, remove); + return; + } + await remove(); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts new file mode 100644 index 00000000000..a70dfa16a17 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -0,0 +1,102 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; + +describe('autoramp-syncing/format-utils', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + updatedAt: 1000, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + it('creates storage keys from id', () => { + expect(createAutorampStorageKey(account)).toBe('ar-1'); + expect(createAutorampStorageKey('ar-2')).toBe('ar-2'); + }); + + it('detects syncable autoramps', () => { + expect(isSyncableAutoramp(account)).toBe(true); + expect(isSyncableAutoramp({ id: '' })).toBe(false); + expect(isSyncableAutoramp(null)).toBe(false); + }); + + it('maps to user storage without deposit rails', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + notifiedForStatus: AutorampStatus.Approved, + }); + + expect(entry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + lu: 1000, + }); + expect(entry.o).not.toHaveProperty('depositRailsSummary'); + }); + + it('round-trips storage entries and strips deletedAt', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + deletedAt: 2000, + }); + const mapped = mapUserStorageEntryToAutoramp(entry); + expect(mapped.deletedAt).toBe(2000); + expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt'); + }); + + it('stamps the current time when the account has no update timestamp', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + updatedAt: 0, + }); + + expect(entry.lu).toBeGreaterThan(0); + expect(entry.o).not.toHaveProperty('notifiedForStatus'); + expect(entry).not.toHaveProperty('dt'); + }); + + it('normalizes a notified status and defaults a missing timestamp', () => { + const mapped = mapUserStorageEntryToAutoramp({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + }); + + expect(mapped.notifiedForStatus).toBe(AutorampStatus.Approved); + expect(mapped.updatedAt).toBeGreaterThan(0); + expect(mapped).not.toHaveProperty('deletedAt'); + }); + + it('compares sync-relevant fields', () => { + expect(areAutorampsEqual(account, { ...account })).toBe(true); + expect( + areAutorampsEqual(account, { + ...account, + status: AutorampStatus.Authorized, + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts new file mode 100644 index 00000000000..13af41c9249 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts @@ -0,0 +1,121 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { normalizeAutorampStatus } from '../autorampAccount.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncAutorampAccount, UserStorageAutorampEntry } from './types.js'; + +/** + * Storage key for an autoramp entry (MoonPay autoramp id). + * + * @param account - Autoramp account or id-bearing object. + * @returns Storage key string. + */ +export function createAutorampStorageKey( + account: Pick | string, +): string { + return typeof account === 'string' ? account : account.id; +} + +/** + * Whether an autoramp has the minimum fields required to sync. + * + * @param account - Candidate autoramp. + * @returns True when syncable. + */ +export function isSyncableAutoramp( + account: Partial | null | undefined, +): account is AutorampAccount { + return Boolean( + account && + typeof account.id === 'string' && + account.id.length > 0 && + typeof account.customerId === 'string' && + typeof account.walletAddress === 'string' && + account.status, + ); +} + +/** + * Map a local autoramp to a User Storage entry (strips depositRailsSummary). + * + * @param account - Local or sync-aware autoramp. + * @returns Compact storage entry. + */ +export function mapAutorampToUserStorageEntry( + account: SyncAutorampAccount, +): UserStorageAutorampEntry { + const now = Date.now(); + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: account.id, + customerId: account.customerId, + walletAddress: account.walletAddress, + status: account.status, + lastSeenStatus: account.lastSeenStatus, + ...(account.notifiedForStatus + ? { notifiedForStatus: account.notifiedForStatus } + : {}), + }, + lu: account.updatedAt || now, + ...(account.deletedAt ? { dt: account.deletedAt } : {}), + }; +} + +/** + * Map a User Storage entry back to a sync-aware autoramp account. + * + * @param entry - Remote storage entry. + * @returns Sync autoramp (no depositRailsSummary). + */ +export function mapUserStorageEntryToAutoramp( + entry: UserStorageAutorampEntry, +): SyncAutorampAccount { + return { + id: entry.o.id, + customerId: entry.o.customerId, + walletAddress: entry.o.walletAddress, + status: normalizeAutorampStatus(entry.o.status), + lastSeenStatus: normalizeAutorampStatus(entry.o.lastSeenStatus), + ...(entry.o.notifiedForStatus + ? { + notifiedForStatus: normalizeAutorampStatus(entry.o.notifiedForStatus), + } + : {}), + updatedAt: entry.lu ?? Date.now(), + ...(entry.dt ? { deletedAt: entry.dt } : {}), + }; +} + +/** + * Strip sync-only metadata before writing into controller state. + * + * @param account - Sync-aware autoramp. + * @returns Plain {@link AutorampAccount}. + */ +export function stripAutorampSyncMetadata( + account: SyncAutorampAccount, +): AutorampAccount { + const { deletedAt: _deletedAt, ...rest } = account; + return rest; +} + +/** + * Compare syncable fields for equality (ignores depositRailsSummary). + * + * @param left - First account. + * @param right - Second account. + * @returns True when sync-relevant fields match. + */ +export function areAutorampsEqual( + left: SyncAutorampAccount, + right: SyncAutorampAccount, +): boolean { + return ( + left.id === right.id && + left.customerId === right.customerId && + left.walletAddress === right.walletAddress && + left.status === right.status && + left.lastSeenStatus === right.lastSeenStatus && + left.notifiedForStatus === right.notifiedForStatus + ); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/index.ts b/packages/ramps-controller/src/autoramp-syncing/index.ts new file mode 100644 index 00000000000..f8dd8064634 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/index.ts @@ -0,0 +1,28 @@ +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './types.js'; +export { + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, + areAutorampsEqual, +} from './format-utils.js'; +export { canPerformAutorampSyncing } from './sync-utils.js'; +export { + computeAutorampMergePlan, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, +} from './controller-integration.js'; diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts new file mode 100644 index 00000000000..1b7c83dc45f --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts @@ -0,0 +1,128 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { computeAutorampMergePlan } from './controller-integration.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { AutorampSyncingOptions } from './types.js'; + +describe('autoramp-syncing/sync-utils', () => { + it('returns false when messenger actions are unavailable', () => { + const options: AutorampSyncingOptions = { + getMessenger: () => + ({ + call: () => { + throw new Error('not delegated'); + }, + }) as AutorampSyncingOptions['getMessenger'] extends () => infer R + ? R + : never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: (): [] => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + }; + + expect(canPerformAutorampSyncing(options)).toBe(false); + }); + + it('returns true when B&S and auth gates pass', () => { + const call = jest.fn((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw new Error(`unexpected ${action}`); + }); + + const options = { + getMessenger: () => ({ call }) as never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: (): [] => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + } as AutorampSyncingOptions; + + expect(canPerformAutorampSyncing(options)).toBe(true); + }); +}); + +describe('autoramp-syncing/computeAutorampMergePlan', () => { + it('imports remote-only accounts and uploads local-only accounts', () => { + const local = createAutorampAccount({ + id: 'local-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = createAutorampAccount({ + id: 'remote-1', + customerId: 'c', + walletAddress: '0x2', + status: AutorampStatus.Approved, + updatedAt: 20, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally.map((a) => a.id)).toStrictEqual([ + 'remote-1', + ]); + expect(plan.accountsToUpdateRemotely.map((a) => a.id)).toStrictEqual([ + 'local-1', + ]); + }); + + it('prefers newer timestamp on conflicts', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 50, + }); + const remote = { + ...createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Approved, + updatedAt: 10, + }), + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToUpdateRemotely).toHaveLength(1); + expect(plan.accountsToAddOrUpdateLocally).toHaveLength(0); + }); + + it('applies remote tombstones when local is older', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = { + ...local, + deletedAt: 20, + updatedAt: 20, + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToDeleteLocally).toHaveLength(1); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts new file mode 100644 index 00000000000..b95a51015f9 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts @@ -0,0 +1,49 @@ +import type { AutorampSyncingOptions } from './types.js'; + +/** + * Check if we can perform autoramp User Storage syncing. + * + * Requires Backup & Sync enabled, signed-in auth, and no in-progress sync. + * Optional `isRampsSyncingEnabled` on User Storage state defaults to true when absent. + * + * @param options - Sync options. + * @returns Whether sync can run. + */ +export function canPerformAutorampSyncing( + options: AutorampSyncingOptions, +): boolean { + const { getMessenger, getRampsControllerInstance } = options; + + try { + const userStorageState = getMessenger().call( + 'UserStorageController:getState', + ) as { + isBackupAndSyncEnabled?: boolean; + isRampsSyncingEnabled?: boolean; + }; + + const isBackupAndSyncEnabled = Boolean( + userStorageState.isBackupAndSyncEnabled, + ); + const isRampsSyncingEnabled = + userStorageState.isRampsSyncingEnabled ?? true; + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + const { isAutorampSyncingInProgress } = getRampsControllerInstance(); + + if ( + !isBackupAndSyncEnabled || + !isRampsSyncingEnabled || + isAutorampSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; + } catch { + // Host has not delegated User Storage / auth actions yet. + return false; + } +} diff --git a/packages/ramps-controller/src/autoramp-syncing/types.ts b/packages/ramps-controller/src/autoramp-syncing/types.ts new file mode 100644 index 00000000000..b3b732e6192 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/types.ts @@ -0,0 +1,70 @@ +import type { TraceCallback } from '@metamask/controller-utils'; + +import type { AutorampAccount } from '../autorampAccount.js'; +import type { RampsControllerMessenger } from '../RampsController.js'; +import type { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; + +/** + * Compact User Storage entry for an autoramp account. + * Omits deposit rail details — those are re-fetched from the Ramp API / MoonPay. + */ +export type UserStorageAutorampEntry = { + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + o: { + id: string; + customerId: string; + walletAddress: string; + status: string; + lastSeenStatus: string; + notifiedForStatus?: string; + }; + lu?: number; + dt?: number; +}; + +/** + * {@link AutorampAccount} plus optional soft-delete metadata for sync merge. + */ +export type SyncAutorampAccount = AutorampAccount & { + deletedAt?: number; +}; + +/** + * Minimal controller surface required by autoramp syncing. + */ +export type AutorampSyncingController = { + state: { + autoramps: AutorampAccount[]; + }; + readonly isAutorampSyncingInProgress: boolean; + setIsAutorampSyncingInProgress: (value: boolean) => void; + setIsApplyingAutorampSyncChanges: (value: boolean) => void; + addAutoramp: (account: AutorampAccount) => AutorampAccount; + removeAutoramp: (autorampId: string) => void; + getPendingRemoteAutorampDeletes: () => AutorampAccount[]; + acknowledgePendingRemoteAutorampDeletes: ( + accounts: AutorampAccount[], + ) => void; +}; + +/** + * Options for autoramp syncing operations. + */ +export type AutorampSyncingOptions = { + getRampsControllerInstance: () => AutorampSyncingController; + getMessenger: () => RampsControllerMessenger; + trace?: TraceCallback; +}; + +/** + * Optional callbacks for sync error reporting. + */ +export type SyncAutorampsWithUserStorageConfig = { + onAutorampSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}; diff --git a/packages/ramps-controller/src/autorampAccount.test.ts b/packages/ramps-controller/src/autorampAccount.test.ts new file mode 100644 index 00000000000..d5518956a98 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.test.ts @@ -0,0 +1,163 @@ +import type { + ApplyAutorampRemoteStatusResult, + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + AutorampStatus, + applyAutorampRemoteStatus, + createAutorampAccount, + isTerminalAutorampStatus, + markAutorampNotified, + normalizeAutorampStatus, +} from './autorampAccount.js'; + +describe('autorampAccount', () => { + describe('normalizeAutorampStatus', () => { + it('returns known statuses as-is', () => { + expect(normalizeAutorampStatus(AutorampStatus.Approved)).toBe( + AutorampStatus.Approved, + ); + expect(normalizeAutorampStatus('DepositAccountAdded')).toBe( + AutorampStatus.DepositAccountAdded, + ); + }); + + it('falls back to Created for unknown values', () => { + expect(normalizeAutorampStatus('Nope')).toBe(AutorampStatus.Created); + }); + }); + + describe('isTerminalAutorampStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalAutorampStatus(AutorampStatus.Rejected)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Cancelled)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Approved)).toBe(false); + expect(isTerminalAutorampStatus(AutorampStatus.Authorized)).toBe(false); + }); + }); + + describe('createAutorampAccount', () => { + it('defaults status to Authorized and mirrors lastSeenStatus', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + updatedAt: 1000, + }); + + expect(account).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1000, + depositRailsSummary: undefined, + }); + }); + }); + + describe('applyAutorampRemoteStatus', () => { + const baseLocal: AutorampAccount = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + updatedAt: 1, + }); + + it('creates a local account without notify when local is null', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }; + + const result = applyAutorampRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + }); + + it('detects Approved transition and requests notify once', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }; + + const result = applyAutorampRemoteStatus(baseLocal, remote); + + expect(result).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.lastSeenStatus).toBe(AutorampStatus.Authorized); + }); + + it('does not notify again when already notified for that status', () => { + const local = markAutorampNotified({ + ...baseLocal, + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Authorized, + notifiedForStatus: AutorampStatus.Approved, + }); + + const result = applyAutorampRemoteStatus(local, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + }); + + it('does not notify for non-notable transitions', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.DepositAccountAdded, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(false); + }); + + it('notifies for Rejected', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Rejected, + }); + + expect(result.shouldNotify).toBe(true); + }); + }); + + describe('markAutorampNotified', () => { + it('sets notifiedForStatus to current status', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + + expect(markAutorampNotified(account).notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); +}); diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts new file mode 100644 index 00000000000..eca7f7fa382 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -0,0 +1,247 @@ +/** + * Local + remote models for MoonPay Enterprise autoramp accounts. + * Separate from {@link RampsOrder}: autoramps are standing routes; orders are payments. + */ + +/** + * Autoramp lifecycle statuses from MoonPay Enterprise. + * + * @see https://dev.enterprise.moonpay.com/autoramp-status + */ +export enum AutorampStatus { + Created = 'Created', + Authorized = 'Authorized', + EditPending = 'EditPending', + DepositAccountAdded = 'DepositAccountAdded', + Approved = 'Approved', + Rejected = 'Rejected', + Cancelled = 'Cancelled', +} + +/** + * Non-PII deposit readiness summary cached after a remote refresh. + * Full deposit rail details (IBAN, etc.) should be re-fetched when needed — not synced. + */ +export type AutorampDepositRailsSummary = { + /** Source currency code when known (e.g. EUR). */ + currency?: string; + /** True when the autoramp is approved and deposit details may be shared. */ + ready: boolean; +}; + +/** + * Local controller representation of an autoramp account. + */ +export type AutorampAccount = { + /** MoonPay autoramp id. */ + id: string; + /** MoonPay customer id. */ + customerId: string; + /** Destination wallet address associated with this autoramp. */ + walletAddress: string; + /** Latest status from MoonPay (source of truth after refresh). */ + status: AutorampStatus; + /** + * Status observed before the most recent remote apply. + * Used for transition UX / analytics (e.g. Authorized → Approved). + */ + lastSeenStatus: AutorampStatus; + /** + * Last status for which the UI already showed a notification. + * Prevents duplicate toasts across refresh and push. + */ + notifiedForStatus?: AutorampStatus; + /** Epoch ms of the last local update from remote or push. */ + updatedAt: number; + /** Optional non-PII deposit readiness cache. */ + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Controller-facing payload for creating an autoramp. + * + * Mirrors the MoonPay `POST /api/autoramps` body that + * {@link NeoBankService.createAutoramp} forwards opaquely, minus `customer_id`: + * `RampsController.createAutoramp` injects the vendor customer id resolved from + * the KYC controller, so callers never supply (or need to know) it. + */ +export type CreateAutorampRequest = Record; + +/** + * Minimal remote snapshot from `GET /api/autoramps/{id}` (or a push payload). + * Host apps / BFF map MoonPay responses into this shape. + */ +export type AutorampRemoteSnapshot = { + id: string; + customerId: string; + walletAddress?: string; + status: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Result of applying a remote autoramp snapshot onto local state. + */ +export type ApplyAutorampRemoteStatusResult = { + account: AutorampAccount; + previousStatus: AutorampStatus; + statusChanged: boolean; + /** True when status changed and UI has not yet notified for the new status. */ + shouldNotify: boolean; +}; + +/** + * Terminal autoramp statuses — no further lifecycle progress expected. + */ +export const TERMINAL_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Statuses that commonly warrant user-visible transition UX (toast / banner). + */ +export const NOTABLE_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Approved, + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Whether an autoramp status is terminal. + * + * @param status - Status to test. + * @returns Whether the status is terminal. + */ +export function isTerminalAutorampStatus(status: AutorampStatus): boolean { + return TERMINAL_AUTORAMP_STATUSES.has(status); +} + +/** + * Normalize a remote status string into {@link AutorampStatus}. + * Unknown values fall back to {@link AutorampStatus.Created}. + * + * @param status - Remote status string. + * @returns A known {@link AutorampStatus}. + */ +export function normalizeAutorampStatus( + status: AutorampStatus | string, +): AutorampStatus { + if (Object.values(AutorampStatus).includes(status as AutorampStatus)) { + return status as AutorampStatus; + } + return AutorampStatus.Created; +} + +/** + * Build a new local autoramp account from create/response fields. + * + * @param input - Required identity + status fields. + * @param input.id - MoonPay autoramp id. + * @param input.customerId - MoonPay customer id. + * @param input.walletAddress - Destination wallet address. + * @param input.status - Optional remote status (defaults to Authorized). + * @param input.depositRailsSummary - Optional non-PII deposit readiness cache. + * @param input.updatedAt - Optional epoch ms timestamp (defaults to now). + * @returns A new {@link AutorampAccount}. + */ +export function createAutorampAccount(input: { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; + updatedAt?: number; +}): AutorampAccount { + const status = normalizeAutorampStatus( + input.status ?? AutorampStatus.Authorized, + ); + return { + id: input.id, + customerId: input.customerId, + walletAddress: input.walletAddress, + status, + lastSeenStatus: status, + updatedAt: input.updatedAt ?? Date.now(), + depositRailsSummary: input.depositRailsSummary, + }; +} + +/** + * Apply a remote autoramp snapshot onto a local account for transition detection. + * Pure helper — shared by refresh-on-load and websocket push paths. + * + * @param local - Current local account (or null when first upserting from remote). + * @param remote - Remote snapshot (MoonPay GET or push). + * @returns Updated account plus change / notify flags. + */ +export function applyAutorampRemoteStatus( + local: AutorampAccount | null, + remote: AutorampRemoteSnapshot, +): ApplyAutorampRemoteStatusResult { + const remoteStatus = normalizeAutorampStatus(remote.status); + + if (!local) { + const account = createAutorampAccount({ + id: remote.id, + customerId: remote.customerId, + walletAddress: remote.walletAddress ?? '', + status: remoteStatus, + depositRailsSummary: remote.depositRailsSummary, + }); + return { + account, + previousStatus: remoteStatus, + statusChanged: false, + shouldNotify: false, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_AUTORAMP_STATUSES.has(remoteStatus); + + const account: AutorampAccount = { + ...local, + id: remote.id, + // A blank remote identity field means "not supplied", not "cleared": the + // proxy omits or empties these on partial status pushes, so keep the local + // value rather than wiping it. + customerId: + remote.customerId.length > 0 ? remote.customerId : local.customerId, + walletAddress: + remote.walletAddress !== undefined && remote.walletAddress.length > 0 + ? remote.walletAddress + : local.walletAddress, + status: remoteStatus, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + depositRailsSummary: + remote.depositRailsSummary ?? local.depositRailsSummary, + }; + + return { + account, + previousStatus, + statusChanged, + shouldNotify, + }; +} + +/** + * Mark that the UI has notified for the account's current status. + * + * @param account - Account to update. + * @returns Account with `notifiedForStatus` set to current status. + */ +export function markAutorampNotified( + account: AutorampAccount, +): AutorampAccount { + return { + ...account, + notifiedForStatus: account.status, + }; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f1d1dcdbe6d..9b4642c524d 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -6,11 +6,14 @@ export type { RampsControllerState, RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, + RampsControllerAutorampStatusChangedEvent, RampsControllerOptions, UserRegion, ResourceState, TransakState, NativeProvidersState, + MoneyAccountWalletRegistrationResult, + KeyringControllerSignPersonalMessageAction, } from './RampsController.js'; export type { RampsControllerExecuteRequestAction, @@ -29,6 +32,15 @@ export type { RampsControllerGetQuotesAction, RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, + RampsControllerAddAutorampAction, + RampsControllerCreateAutorampAction, + RampsControllerRemoveAutorampAction, + RampsControllerRegisterMoneyAccountWalletAction, + RampsControllerMarkAutorampAsNotifiedAction, + RampsControllerApplyAutorampStatusFromPushAction, + RampsControllerRefreshAutorampAction, + RampsControllerRefreshAutorampsAction, + RampsControllerSyncAutorampsWithUserStorageAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, RampsControllerGetBuyWidgetDataAction, @@ -67,6 +79,8 @@ export { getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; export type { RampsServiceActions, @@ -165,6 +179,66 @@ export { TERMINAL_ORDER_STATUSES, isTerminalOrderStatus, } from './orderStatus.js'; +export type { + AutorampAccount, + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, + ApplyAutorampRemoteStatusResult, + CreateAutorampRequest, +} from './autorampAccount.js'; +export { + AutorampStatus, + TERMINAL_AUTORAMP_STATUSES, + NOTABLE_AUTORAMP_STATUSES, + isTerminalAutorampStatus, + normalizeAutorampStatus, + createAutorampAccount, + applyAutorampRemoteStatus, + markAutorampNotified, +} from './autorampAccount.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './autoramp-syncing/index.js'; +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, + canPerformAutorampSyncing, + computeAutorampMergePlan, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, +} from './autoramp-syncing/index.js'; +export type { + NeoBankServiceActions, + NeoBankServiceEvents, + NeoBankServiceMessenger, + NeoBankAutorampResponse, + NeoBankRequestOptions, + NeoBankQueryParams, + GetWalletRegistrationStatusParams, + RegisterSelfHostedWalletParams, +} from './NeoBankService.js'; +export type { + NeoBankServiceGetAutorampAction, + NeoBankServiceRegisterPixAddressAction, + NeoBankServiceGetAutorampQuoteAction, + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampQuoteForAutorampAction, + NeoBankServiceAttachAutorampQuoteAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetMoonpayCustomerIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, +} from './NeoBankService-method-action-types.js'; +export { + NeoBankService, + serviceName as neoBankServiceName, + mapNeoBankAutorampToRemoteSnapshot, +} from './NeoBankService.js'; export type { TypedError } from './errorNormalization.js'; export { getErrorMessage, @@ -220,3 +294,13 @@ export type { TransakServiceGeneratePaymentWidgetUrlAction, TransakServiceCreateWidgetUrlAction, } from './TransakService-method-action-types.js'; + +export type { + Blockchain, + RegistrationOutcome, + RegistrationStatus, + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; +export { buildOwnershipMessage } from './ownership-message.js'; diff --git a/packages/ramps-controller/src/ownership-message.test.ts b/packages/ramps-controller/src/ownership-message.test.ts new file mode 100644 index 00000000000..071144a4642 --- /dev/null +++ b/packages/ramps-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/ramps-controller/src/ownership-message.ts b/packages/ramps-controller/src/ownership-message.ts new file mode 100644 index 00000000000..539d5e3aac3 --- /dev/null +++ b/packages/ramps-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/ramps-controller/src/wallet-registration-machine.test.ts b/packages/ramps-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 00000000000..de58a81ef12 --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/ramps-controller/src/wallet-registration-machine.ts b/packages/ramps-controller/src/wallet-registration-machine.ts new file mode 100644 index 00000000000..3251c56d02e --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/ramps-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts new file mode 100644 index 00000000000..d76327eeba0 --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-service.test.ts @@ -0,0 +1,709 @@ +import { + createIdempotencyKey, + extractErrorBody, + WalletRegistrationError, + WalletRegistrationService, +} from './wallet-registration-service.js'; + +const BASE_URL = 'https://on-ramp.dev-api.cx.metamask.io'; +const AUTH_TOKEN = 'session-jwt-abc'; +const EXTERNAL_ID = 'canonical-profile-1'; +const CUSTOMER_ID = '019ff69c-3039-77b0-9d5d-e4a3baefd7b7'; + +type FetchInit = { + method?: string; + headers: Record; + body?: string; +}; + +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: unknown; + }, +) => Promise; + +const jsonResponse = (status: number, body: unknown): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => body, + text: async (): Promise => + typeof body === 'string' ? body : JSON.stringify(body), +}); + +const textResponse = (status: number, body: string): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => JSON.parse(body), + text: async (): Promise => body, +}); + +const invalidJsonResponse = (status: number): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error('invalid json'); + }, + text: async (): Promise => 'not json', +}); + +const buildService = (fetchImpl: FetchLike): WalletRegistrationService => + new WalletRegistrationService({ + fetch: fetchImpl, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => EXTERNAL_ID, + }); + +const verifiedAddress = ( + overrides: Record = {}, +): Record => ({ + id: 'addr-1', + wallet_address: '0xAbC0000000000000000000000000000000000001', + blockchain: 'Monad', + address_type: 'SelfHosted', + disabled: false, + is_self: true, + proof_message: 'I am verifying ownership...', + proof_signature: '0xsig', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; + +describe('createIdempotencyKey', () => { + it('returns a non-empty string', () => { + expect(createIdempotencyKey().length).toBeGreaterThan(0); + }); + + // `globalThis.crypto.randomUUID` is absent under Node 18, so the preferred + // path has to be exercised against an installed stub rather than the ambient + // runtime. + it('prefers randomUUID when the runtime provides it', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: () => 'uuid-1' }, + }); + try { + expect(createIdempotencyKey()).toBe('uuid-1'); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } else { + // Node 18 exposes no own `crypto` descriptor, so the stub has to be + // removed rather than restored, or it leaks into later tests. + Reflect.deleteProperty(globalThis, 'crypto'); + } + } + }); + + it('falls back when randomUUID is unavailable', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: undefined }, + }); + try { + expect(createIdempotencyKey()).toMatch(/^wallet-reg-/u); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } + } + }); +}); + +describe('extractErrorBody', () => { + it('returns whitespace-only bodies unchanged', () => { + expect(extractErrorBody(' ')).toBe(' '); + expect(extractErrorBody('')).toBe(''); + }); + + it('unwraps a JSON-encoded string', () => { + expect(extractErrorBody(JSON.stringify('already exists'))).toBe( + 'already exists', + ); + }); + + it('prefers message on a JSON object', () => { + expect(extractErrorBody(JSON.stringify({ message: 'forbidden' }))).toBe( + 'forbidden', + ); + }); + + it('keeps a JSON object without message as raw text', () => { + const raw = JSON.stringify({ code: 'x', detail: 'nope' }); + expect(extractErrorBody(raw)).toBe(raw); + }); + + it('returns plain text that is not JSON', () => { + expect(extractErrorBody('not json at all')).toBe('not json at all'); + }); + + it('returns non-object JSON values as the raw trimmed text', () => { + expect(extractErrorBody('null')).toBe('null'); + expect(extractErrorBody('42')).toBe('42'); + expect(extractErrorBody('true')).toBe('true'); + }); +}); + +describe('WalletRegistrationService.getMoonpayCustomerId', () => { + it('returns Iron customer id from GET /neobank/customers/{external_id}/external', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { + id: 'iron-customer-1', + external_id: EXTERNAL_ID, + status: 'Active', + }), + ); + + expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( + 'iron-customer-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/neobank/customers/${EXTERNAL_ID}/external`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${AUTH_TOKEN}`, + }), + }), + ); + }); + + it('maps a failed customer lookup to a typed HTTP error with transparent body', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(404, 'not found'), + ); + + await expect( + buildService(fetchMock).getMoonpayCustomerId(), + ).rejects.toMatchObject({ + kind: 'notFound', + httpStatus: 404, + body: 'not found', + }); + }); + + it('rejects malformed customer lookup responses', async () => { + await expect( + buildService( + jest.fn(async (): Promise => invalidJsonResponse(200)), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + + await expect( + buildService( + jest.fn(async (): Promise => jsonResponse(200, {})), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects an empty external id before calling the network', async () => { + const fetchMock = jest.fn(); + const service = new WalletRegistrationService({ + fetch: fetchMock, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => '', + }); + + await expect(service.getMoonpayCustomerId()).rejects.toMatchObject({ + kind: 'malformedResponse', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe('WalletRegistrationService.getRegistrationStatus', () => { + it('lists via /neobank/addresses/crypto/{customer_id}?filter=SelfHosted', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, []), + ); + const service = buildService(fetchMock); + + await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe( + `${BASE_URL}/neobank/addresses/crypto/${CUSTOMER_ID}?filter=SelfHosted`, + ); + expect(url).not.toContain('iron.xyz'); + expect(url).not.toContain('/vendors/moonpay/'); + expect(init.method).toBe('GET'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + }); + + it('returns an active match parsed from wallet_address (Monad filter client-side)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + verifiedAddress({ blockchain: 'Ethereum' }), + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: '0xabc0000000000000000000000000000000000001', + blockchain: 'Monad', + }); + + expect(status).toMatchObject({ + type: 'active', + registration: { address: EVM_ADDRESS, disabled: false }, + }); + }); + + it('returns a disabled result when the matching address is disabled', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ disabled: true })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('disabled'); + }); + + it('scopes matching per blockchain (same address, different chain is absent)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('absent'); + }); + + it('skips entries whose wallet_address is not a string', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + { id: 'junk', wallet_address: 12345, blockchain: 'Monad' }, + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('active'); + }); + + it('throws malformedResponse when the list body is not valid JSON', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('throws a lookupUnavailable error on a non-2xx list response', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'boom'), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable', body: 'boom' }); + }); + + it('throws a lookupUnavailable error when the list body is malformed', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toBeInstanceOf(WalletRegistrationError); + }); + + it('never converts a network failure during lookup into "absent"', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('network down')); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('handles a non-Error thrown during lookup', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('string failure'); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); +}); + +const registerRequest = { + customerId: CUSTOMER_ID, + address: EVM_ADDRESS, + blockchain: 'Monad' as const, + message: 'I am verifying ownership ...', + signature: '0xdeadbeef', +}; + +const selfHostedResponse = ( + overrides: Record = {}, +): Record => ({ + id: 'wallet-1', + address: EVM_ADDRESS, + customer_id: CUSTOMER_ID, + disabled: false, + signature: '0xdeadbeef', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +describe('WalletRegistrationService.registerSelfHostedWallet', () => { + it('posts to /neobank/addresses/crypto/selfhosted with an idempotency key', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + const outcome = await service.registerSelfHostedWallet({ + ...registerRequest, + idempotencyKey: 'idem-wallet-1', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/neobank/addresses/crypto/selfhosted`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(init.headers['Idempotency-Key']).toBe('idem-wallet-1'); + expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ + customer_id: registerRequest.customerId, + address: registerRequest.address, + blockchain: 'Monad', + message: registerRequest.message, + signature: registerRequest.signature, + }); + expect(outcome.registration).toMatchObject({ + id: 'wallet-1', + address: registerRequest.address, + disabled: false, + }); + }); + + it('generates an Idempotency-Key when the caller omits one', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + await service.registerSelfHostedWallet(registerRequest); + + const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(init.headers['Idempotency-Key']?.length).toBeGreaterThan(0); + }); + + it('maps a plain-string 409 body to an ambiguous conflict error', async () => { + const fetchMock = jest.fn( + async (): Promise => + textResponse( + 409, + 'A crypto address with this wallet address already exists', + ), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'conflict', + httpStatus: 409, + body: 'A crypto address with this wallet address already exists', + }); + }); + + it('maps 5xx to a transient error', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'internal error'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 }); + }); + + it('maps a network failure / timeout to a transient error', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('ETIMEDOUT')); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps a non-Error thrown during registration to transient', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('socket hang up'); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps 400 to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, 'bad message'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 }); + }); + + it('maps an unmapped 4xx (422) to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(422, 'unprocessable'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 }); + }); + + it('maps 401 to unauthorized', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(401, 'session expired'), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'unauthorized' }); + }); + + it('maps 403 to forbidden and 404 to notFound', async () => { + const forbiddenFetch = jest.fn( + async (): Promise => textResponse(403, 'suspended'), + ); + const notFoundFetch = jest.fn( + async (): Promise => textResponse(404, 'not found'), + ); + + const forbidden = await buildService(forbiddenFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + const notFound = await buildService(notFoundFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + + expect(forbidden).toMatchObject({ kind: 'forbidden' }); + expect(notFound).toMatchObject({ kind: 'notFound' }); + }); + + it('maps a JSON error object with message when present', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(403, { message: 'forbidden' }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'forbidden', body: 'forbidden' }); + }); + + it('maps a JSON-encoded string error body', async () => { + const fetchMock = jest.fn( + async (): Promise => + textResponse(409, JSON.stringify('already exists')), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'conflict', body: 'already exists' }); + }); + + it('keeps a JSON object without message as the raw body', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(400, { code: 'x', detail: 'nope' }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: JSON.stringify({ code: 'x', detail: 'nope' }), + }); + }); + + it('keeps a whitespace-only error body as-is', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ' '), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', body: ' ' }); + }); + + it('omits Error.message when the upstream body is empty', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ''), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: '', + message: 'wallet registration failed: validation', + }); + }); + + it('maps an unreadable error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => ({ + ok: false, + status: 500, + json: async (): Promise => { + throw new Error('no json'); + }, + text: async (): Promise => { + throw new Error('no text'); + }, + }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse', httpStatus: 500 }); + }); + + it('maps 429 to a rateLimited error', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(429, 'slow down'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'rateLimited' }); + }); + + it('maps a malformed 200 body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects a success body that has an id but no address', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { id: 'wallet-1', disabled: false }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON success body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); diff --git a/packages/ramps-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts new file mode 100644 index 00000000000..240f6720474 --- /dev/null +++ b/packages/ramps-controller/src/wallet-registration-service.ts @@ -0,0 +1,457 @@ +/** The only blockchain supported by the Money Account POC. */ +export type Blockchain = 'Monad'; + +/** Normalized view of a single registered self-hosted address. */ +export type SelfHostedRegistration = { + id: string; + address: string; + blockchain: Blockchain; + disabled: boolean; + isSelf: boolean; +}; + +/** Result of reconciling a wallet against the customer's registered addresses. */ +export type RegistrationStatus = + | { type: 'active'; registration: SelfHostedRegistration } + | { type: 'disabled'; registration: SelfHostedRegistration } + | { type: 'absent' }; + +/** + * Discriminated error kinds surfaced to the state machine. Every non-success + * path maps to exactly one of these so the machine can decide deterministically. + */ +export type WalletRegistrationErrorKind = + | 'validation' + | 'unauthorized' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'rateLimited' + | 'transient' + | 'lookupUnavailable' + | 'malformedResponse'; + +/** Minimal HTTP response shape, so the service is environment-agnostic. */ +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +/** Minimal `fetch`-like function the service depends on. */ +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise; + +/** Typed error carrying enough context for state transitions. */ +export class WalletRegistrationError extends Error { + readonly kind: WalletRegistrationErrorKind; + + readonly httpStatus?: number; + + readonly body?: string; + + constructor( + kind: WalletRegistrationErrorKind, + options: { + message?: string; + httpStatus?: number; + body?: string; + }, + ) { + super(options.message ?? `wallet registration failed: ${kind}`); + this.name = 'WalletRegistrationError'; + this.kind = kind; + this.httpStatus = options.httpStatus; + this.body = options.body; + } +} + +export type WalletRegistrationServiceOptions = { + fetch: FetchLike; + /** + * Base URL of the Money Movement neobank-proxy host + * (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are under `/neobank`. + */ + baseUrl: string; + getAuthToken: () => Promise; + /** + * MetaMask profile / partner external id used as MoonPay `external_id` + * (typically `AuthenticationController:getSessionProfile().canonicalProfileId`). + */ + getExternalId: () => Promise; +}; + +export type GetRegistrationStatusRequest = { + customerId: string; + address: string; + blockchain: Blockchain; +}; + +export type RegisterSelfHostedWalletRequest = { + customerId: string; + address: string; + blockchain: Blockchain; + message: string; + signature: string; + /** + * Stable key reused across retries of the same ownership proof. Generated + * when omitted. + */ + idempotencyKey?: string; +}; + +/** Successful registration outcome. */ +export type RegistrationOutcome = { + type: 'registered'; + registration: SelfHostedRegistration; +}; + +/** + * Normalizes a Monad EVM address for case-insensitive comparison. + * + * @param address - Raw address string. + * @returns The comparison key for the address. + */ +function normalizeAddress(address: string): string { + return address.toLowerCase(); +} + +/** + * Maps an HTTP status to the typed error kind the state machine reacts to. + * + * @param status - HTTP status code from the proxy/Iron response. + * @returns The corresponding error kind. + */ +function mapStatusToKind(status: number): WalletRegistrationErrorKind { + switch (status) { + case 400: + return 'validation'; + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'notFound'; + case 409: + return 'conflict'; + case 429: + return 'rateLimited'; + default: + return status >= 500 ? 'transient' : 'validation'; + } +} + +/** + * Builds a client-side Idempotency-Key for MoonPay POSTs. Prefer a stable + * caller-supplied key across retries of the same proof. + * + * @returns A random UUID when available, otherwise a timestamped fallback. + */ +export function createIdempotencyKey(): string { + const cryptoObj = globalThis.crypto as + | { randomUUID?: () => string } + | undefined; + if (typeof cryptoObj?.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `wallet-reg-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Extracts a human-readable error body from a transparent neobank-proxy + * response. Upstream may return a plain string or a JSON value; both are + * mirrored 1:1 (no `{ code: 'iron_error' }` envelope). + * + * @param raw - Raw response text. + * @returns Normalized body string for {@link WalletRegistrationError}. + */ +export function extractErrorBody(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return raw; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === 'string') { + return parsed; + } + if (parsed && typeof parsed === 'object') { + const { message } = parsed as { message?: unknown }; + if (typeof message === 'string') { + return message; + } + } + return trimmed; + } catch { + return trimmed; + } +} + +/** + * Data service that talks to the Money Movement neobank-proxy for MoonPay Iron + * self-hosted wallet registration. It never calls Iron directly, so the Iron + * API key never ships in the client. + */ +export class WalletRegistrationService { + readonly #fetch: FetchLike; + + readonly #baseUrl: string; + + readonly #getAuthToken: () => Promise; + + readonly #getExternalId: () => Promise; + + constructor(options: WalletRegistrationServiceOptions) { + this.#fetch = options.fetch; + this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); + this.#getAuthToken = options.getAuthToken; + this.#getExternalId = options.getExternalId; + } + + /** + * Resolves Iron's internal customer id via + * `GET /neobank/customers/{external_id}/external`, using the MetaMask + * profile/canonical id as `external_id`. Used when the current KYC flow has + * not already received `customer.id` from MoonPay's hosted frame. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + const [token, externalId] = await Promise.all([ + this.#getAuthToken(), + this.#getExternalId(), + ]); + if (!externalId) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MetaMask external id (canonical profile id) is empty', + }); + } + + const response = await this.#fetch( + `${this.#baseUrl}/neobank/customers/${encodeURIComponent(externalId)}/external`, + { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body was not valid JSON', + }); + } + + const { id } = payload as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body missing id', + }); + } + return id; + } + + /** + * Reconciles a wallet against the customer's registered self-hosted addresses + * via `GET /neobank/addresses/crypto/{customer_id}?filter=SelfHosted`. + * Upstream returns all self-hosted chains; Monad filtering stays client-side + * for the POC. A failed or malformed lookup is reported as + * `lookupUnavailable` and never downgraded to `absent`. + * + * @param request - Customer id and Monad address to reconcile. + * @returns The active / disabled / absent status for the address. + */ + async getRegistrationStatus( + request: GetRegistrationStatusRequest, + ): Promise { + const { customerId, address, blockchain } = request; + + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + const url = new URL( + `${this.#baseUrl}/neobank/addresses/crypto/${encodeURIComponent(customerId)}`, + ); + url.searchParams.set('filter', 'SelfHosted'); + response = await this.#fetch(url.toString(), { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + } catch (error) { + throw new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + const body = await response.text(); + throw new WalletRegistrationError('lookupUnavailable', { + httpStatus: response.status, + body: extractErrorBody(body), + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'self-hosted address list body was not valid JSON', + }); + } + if (!Array.isArray(payload)) { + throw new WalletRegistrationError('malformedResponse', { + message: 'expected an array of registered addresses', + }); + } + + const target = normalizeAddress(address); + const match = payload.find((entry) => { + const record = entry as Record; + const walletAddress = record.wallet_address; + if (typeof walletAddress !== 'string') { + return false; + } + return ( + normalizeAddress(walletAddress) === target && + record.blockchain === blockchain + ); + }) as Record | undefined; + + if (!match) { + return { type: 'absent' }; + } + + const registration = this.#toRegistration(match); + return registration.disabled + ? { type: 'disabled', registration } + : { type: 'active', registration }; + } + + /** + * Registers a self-hosted wallet through neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. The client supplies + * `customer_id` and an `Idempotency-Key` (generated when omitted). Every + * non-2xx response is mapped to a typed error; `409` is deliberately + * surfaced as an ambiguous `conflict` that the caller must reconcile with a + * follow-up status lookup. + * + * @param request - Customer id, address, blockchain, message, and signature. + * @returns The registered outcome on success. + */ + async registerSelfHostedWallet( + request: RegisterSelfHostedWalletRequest, + ): Promise { + const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey(); + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch( + `${this.#baseUrl}/neobank/addresses/crypto/selfhosted`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), + }, + ); + } catch (error) { + throw new WalletRegistrationError('transient', { + message: 'self-hosted registration request failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body was not valid JSON', + }); + } + + const record = payload as Record; + if (typeof record.id !== 'string' || typeof record.address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body missing id/address', + }); + } + + return { + type: 'registered', + registration: { + id: record.id, + address: record.address, + blockchain: request.blockchain, + disabled: Boolean(record.disabled), + isSelf: true, + }, + }; + } + + async #toHttpError(response: HttpResponse): Promise { + let raw = ''; + try { + raw = await response.text(); + } catch { + return new WalletRegistrationError('malformedResponse', { + httpStatus: response.status, + message: 'error body could not be read', + }); + } + + const { status } = response; + const kind = mapStatusToKind(status); + const body = extractErrorBody(raw); + return new WalletRegistrationError(kind, { + httpStatus: status, + body, + message: body || undefined, + }); + } + + #toRegistration(record: Record): SelfHostedRegistration { + return { + id: String(record.id), + address: String(record.wallet_address), + blockchain: 'Monad', + disabled: Boolean(record.disabled), + isSelf: Boolean(record.is_self), + }; + } +} diff --git a/packages/react-data-query/src/createUIQueryClient.test.ts b/packages/react-data-query/src/createUIQueryClient.test.ts index 74175d9023a..4a0aa0bb62c 100644 --- a/packages/react-data-query/src/createUIQueryClient.test.ts +++ b/packages/react-data-query/src/createUIQueryClient.test.ts @@ -403,14 +403,14 @@ describe('createUIQueryClient', () => { const observerA = new InfiniteQueryObserver(clientA, { queryKey: getActivityQueryKey, - initialPageParam: null, + initialPageParam: undefined, getNextPageParam, getPreviousPageParam, }); const observerB = new InfiniteQueryObserver(clientB, { queryKey: getActivityQueryKey, - initialPageParam: null, + initialPageParam: undefined, getNextPageParam, getPreviousPageParam, }); diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md index 9ac1a8ff9fd..664b04dc9ae 100644 --- a/packages/remote-feature-flag-controller/CHANGELOG.md +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -9,24 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **BREAKING:** Add required `getCanonicalProfileId` constructor option to `RemoteFeatureFlagController` for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) - - By default, canonical profile ID is used, but MetaMetrics ID can be used when the flag name is present in `metaMetricsFlags`, typically for scenarios when canonical profile ID is unavailable. -- Add optional `metaMetricsFlags` constructor option to `RemoteFeatureFlagController` to segment flags by MetaMetrics ID ([#9325](https://github.com/MetaMask/core/pull/9325)) - - Flags with names present in `metaMetricsFlags` are segmented by MetaMetrics ID; all others segment by canonical profile ID. - Add optional `defaultFeatureFlags` constructor option to `RemoteFeatureFlagController` for client-side defaults as the lowest-precedence layer under processed remote flags and local overrides ([#9747](https://github.com/MetaMask/core/pull/9747)) -### Changed - -- **BREAKING:** Add `RemoteFeatureFlagController.init` method ([#9816](https://github.com/MetaMask/core/pull/9816)) - - This must be called during initialization to ensure `remoteFeatureFlags` is properly recomputed. -- **BREAKING:** Stop redacting IDs from `rawRemoteFeatureFlags` ([#9816](https://github.com/MetaMask/core/pull/9816)) - - Existing `rawRemoteFeatureFlags` properties should be deleted in a migration, so they do not get used for recomputing flags (which would not work properly with a redacted input). - -### Fixed - -- Restore remote flag value when overrides are removed/cleared ([#9816](https://github.com/MetaMask/core/pull/9816)) - - Previously the underlying remote value would be removed as well. - ## [5.0.0] ### Added diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts index 5df6a8c4416..e9098ffcb33 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -42,17 +42,11 @@ const MOCK_FLAGS_WITH_THRESHOLD = { scope: { type: 'threshold', value: 0.5 }, value: 'valueB', }, - { - name: 'groupC', - scope: { type: 'threshold', value: 1 }, - value: 'valueC', - }, + { name: 'groupC', scope: { type: 'threshold', value: 1 }, value: 'valueC' }, ], }; const MOCK_METRICS_ID = 'f9e8d7c6-b5a4-4210-9876-543210fedcba'; -const MOCK_CANONICAL_ID = - '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420'; const MOCK_BASE_VERSION = '13.10.0'; /** @@ -63,8 +57,6 @@ const MOCK_BASE_VERSION = '13.10.0'; * @param options.clientConfigApiService - The client config API service instance * @param options.disabled - Whether the controller should start disabled * @param options.getMetaMetricsId - Returns metaMetricsId - * @param options.getCanonicalProfileId - Returns canonicalProfileId - * @param options.metaMetricsFlags - Names of feature flags that should use MetaMetrics ID * @param options.clientVersion - The client version string * @param options.prevClientVersion - The previous client version string * @param options.defaultFeatureFlags - Client-side default feature flags @@ -76,8 +68,6 @@ function createController( clientConfigApiService: AbstractClientConfigApiService; disabled: boolean; getMetaMetricsId: () => string; - getCanonicalProfileId: () => string; - metaMetricsFlags: readonly string[]; clientVersion: string; prevClientVersion: string; defaultFeatureFlags: FeatureFlags; @@ -93,10 +83,6 @@ function createController( getMetaMetricsId: options.getMetaMetricsId ?? ((): typeof MOCK_METRICS_ID => MOCK_METRICS_ID), - getCanonicalProfileId: - options.getCanonicalProfileId ?? - ((): typeof MOCK_CANONICAL_ID => MOCK_CANONICAL_ID), - metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? MOCK_BASE_VERSION, prevClientVersion: options.prevClientVersion, defaultFeatureFlags: options.defaultFeatureFlags, @@ -488,7 +474,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlagForThreshold'], }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -524,7 +509,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( @@ -539,51 +523,6 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('preserves the threshold array when no group covers the threshold', async () => { - const unreachableGroups = [ - { name: 'groupA', scope: { type: 'threshold', value: 0 }, value: 'a' }, - ]; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { unreachableFlag: unreachableGroups }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => MOCK_METRICS_ID, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect(controller.state.remoteFeatureFlags.unreachableFlag).toStrictEqual( - unreachableGroups, - ); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); - }); - - it('selects an unnamed threshold group without recording a group name', async () => { - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { - unnamedGroupFlag: [ - { scope: { type: 'threshold', value: 1 }, value: 'selected' }, - ], - }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => MOCK_METRICS_ID, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect(controller.state.remoteFeatureFlags.unnamedGroupFlag).toBe( - 'selected', - ); - expect(controller.state.featureFlagThresholdGroups).toStrictEqual({}); - }); - it('preserves non-threshold feature flags unchanged', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_THRESHOLD, @@ -591,7 +530,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -636,7 +574,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['featureA', 'featureB'], }); // Act @@ -669,7 +606,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); // Act @@ -706,7 +642,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['mixedArray'], }); // Act @@ -748,7 +683,6 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger1.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -759,7 +693,6 @@ describe('RemoteFeatureFlagController', () => { createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger2.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', @@ -774,74 +707,10 @@ describe('RemoteFeatureFlagController', () => { testFlag: 'control', }); }); - - it('uses getCanonicalProfileId for threshold flags absent from metaMetricsFlags', async () => { - const mockFlags = { - canonicalThresholdFlag: [ - { - name: 'groupA', - scope: { type: 'threshold', value: 0.5 }, - value: 'canonicalA', - }, - { - name: 'groupB', - scope: { type: 'threshold', value: 1.0 }, - value: 'canonicalB', - }, - ], - }; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: mockFlags, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => '', - getCanonicalProfileId: () => MOCK_CANONICAL_ID, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect(controller.state.remoteFeatureFlags.canonicalThresholdFlag).toBe( - 'canonicalB', - ); - expect(controller.state.thresholdCache).toStrictEqual({ - [`${MOCK_CANONICAL_ID}:canonicalThresholdFlag`]: expect.any(Number), - }); - }); - - it('preserves threshold arrays when canonical profile id is empty', async () => { - const mockFlags = { - canonicalThresholdFlag: [ - { - name: 'groupA', - scope: { type: 'threshold', value: 1.0 }, - value: 'canonicalA', - }, - ], - }; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: mockFlags, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => MOCK_METRICS_ID, - getCanonicalProfileId: () => '', - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - expect( - controller.state.remoteFeatureFlags.canonicalThresholdFlag, - ).toStrictEqual(mockFlags.canonicalThresholdFlag); - }); }); describe('metaMetricsIds explicit targeting', () => { - const MOCK_FLAGS_WITH_EXPLICIT_IDS: FeatureFlags = { + const MOCK_FLAGS_WITH_EXPLICIT_IDS = { testFlag: [ { name: 'qaGroup', @@ -869,7 +738,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( @@ -883,7 +751,7 @@ describe('RemoteFeatureFlagController', () => { }); it('first entry with a matching metaMetricsId wins when multiple entries match', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'first', @@ -905,7 +773,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( @@ -919,7 +786,7 @@ describe('RemoteFeatureFlagController', () => { }); it('falls back to hash-based threshold when no entry matches the metaMetricsId', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'qaGroup', @@ -945,7 +812,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( @@ -960,7 +826,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores entries with a malformed metaMetricsIds (non-array) and falls back to hash-based threshold', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'badGroup', @@ -986,14 +852,13 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - // Malformed entry ignored; hash of MOCK_METRICS_ID + 'testFlag' selects groupA + // Malformed entry ignored; hash-based selects groupA expect(controller.state.remoteFeatureFlags.testFlag).toBe('valueA'); expect(controller.state.featureFlagThresholdGroups).toStrictEqual({ testFlag: 'groupA', @@ -1001,7 +866,7 @@ describe('RemoteFeatureFlagController', () => { }); it('ignores non-string items within metaMetricsIds when matching', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'badGroup', @@ -1036,7 +901,7 @@ describe('RemoteFeatureFlagController', () => { }); it('normalizes metaMetricsId with trim and toLowerCase before matching', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'qaGroup', @@ -1090,7 +955,7 @@ describe('RemoteFeatureFlagController', () => { }); it('still populates the threshold cache for hash-based fallback when no explicit ID matches', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { name: 'qaGroup', @@ -1111,7 +976,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], }); await messenger.call( @@ -1123,7 +987,7 @@ describe('RemoteFeatureFlagController', () => { ).toBeDefined(); }); - it('retains metaMetricsIds values in rawRemoteFeatureFlags state so targeting can be re-derived', async () => { + it('does not include metaMetricsIds values in rawRemoteFeatureFlags state', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS, }); @@ -1136,33 +1000,36 @@ describe('RemoteFeatureFlagController', () => { 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - expect(controller.state.rawRemoteFeatureFlags?.testFlag).toStrictEqual( - MOCK_FLAGS_WITH_EXPLICIT_IDS.testFlag, - ); + const rawEntries = controller.state.rawRemoteFeatureFlags + .testFlag as Record[]; + expect( + rawEntries.every((entry) => entry.metaMetricsIds === undefined), + ).toBe(true); }); - it('preserves threshold entries as-is, metaMetricsIds included, when metaMetricsId is unavailable', async () => { + it('does not include metaMetricsIds values in remoteFeatureFlags state when metaMetricsId is unavailable', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS, }); - // This flag segments by MetaMetrics ID, so an unavailable ID leaves the - // threshold array unprocessed. + // No metaMetricsId → threshold arrays are preserved as-is, but + // metaMetricsIds must still be stripped from the processed output. const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', - metaMetricsFlags: ['testFlag'], }); await messenger.call( 'RemoteFeatureFlagController:updateRemoteFeatureFlags', ); - expect(controller.state.remoteFeatureFlags.testFlag).toStrictEqual( - MOCK_FLAGS_WITH_EXPLICIT_IDS.testFlag, - ); + const processedEntries = controller.state.remoteFeatureFlags + .testFlag as Record[]; + expect( + processedEntries.every((entry) => entry.metaMetricsIds === undefined), + ).toBe(true); }); - it('resolves to a single value carrying no metaMetricsIds when an explicit match is found', async () => { + it('does not include metaMetricsIds values in remoteFeatureFlags state when explicit match is found', async () => { const clientConfigApiService = buildClientConfigApiService({ remoteFeatureFlags: MOCK_FLAGS_WITH_EXPLICIT_IDS, }); @@ -1185,7 +1052,7 @@ describe('RemoteFeatureFlagController', () => { }); it('supports ThresholdVersion.DirectValue entries with explicit-ID matching', async () => { - const mockFlags: FeatureFlags = { + const mockFlags = { testFlag: [ { thresholdName: 'qaGroup', @@ -1518,7 +1385,6 @@ describe('RemoteFeatureFlagController', () => { clientConfigApiService: mockApiService, clientVersion: '13.1.5', // Qualifies for 13.1.0 version but not 13.2.0 getMetaMetricsId: () => MOCK_METRICS_ID, // This generates threshold > 0.7 - metaMetricsFlags: ['multiVersionABFlag'], }); await messenger.call( @@ -1624,12 +1490,13 @@ describe('RemoteFeatureFlagController', () => { }); describe('removeFlagOverride', () => { - it('removes a specific override, revealing the remote value beneath it', () => { + it('removes a specific override', () => { const { controller, messenger } = createController({ state: { remoteFeatureFlags: { remoteFlag: 'remoteValue', - flag1: 'remoteValue1', + flag1: 'value1', + flag2: 'value2', }, localOverrides: { flag1: 'value1', @@ -1648,7 +1515,6 @@ describe('RemoteFeatureFlagController', () => { }); expect(controller.state.remoteFeatureFlags).toStrictEqual({ remoteFlag: 'remoteValue', - flag1: 'remoteValue1', flag2: 'value2', }); }); @@ -1685,6 +1551,8 @@ describe('RemoteFeatureFlagController', () => { state: { remoteFeatureFlags: { remoteFlag: 'remoteValue', + flag1: 'value1', + flag2: 'value2', }, localOverrides: { flag1: 'value1', @@ -1746,7 +1614,7 @@ describe('RemoteFeatureFlagController', () => { }); }); - it('uses persisted remoteFeatureFlags with overrides on init', async () => { + it('uses persisted remoteFeatureFlags with overrides on init', () => { const { controller } = createController({ state: { remoteFeatureFlags: { @@ -1759,15 +1627,13 @@ describe('RemoteFeatureFlagController', () => { }, }); - await controller.init(); - expect(controller.state.remoteFeatureFlags).toStrictEqual({ remoteFlag: 'remoteValue', overrideFlag: 'overrideValue', }); }); - it('merges legacy persisted localOverrides into remoteFeatureFlags on init', async () => { + it('merges legacy persisted localOverrides into remoteFeatureFlags on init', () => { const { controller, messenger } = createController({ state: { remoteFeatureFlags: { @@ -1780,8 +1646,6 @@ describe('RemoteFeatureFlagController', () => { }, }); - await controller.init(); - expect(controller.state.remoteFeatureFlags).toStrictEqual({ remoteFlag: 'remoteValue', overrideFlag: 'overrideValue', @@ -1801,7 +1665,7 @@ describe('RemoteFeatureFlagController', () => { }); describe('defaultFeatureFlags', () => { - it('initializes with defaults when no remote or persisted flags exist', async () => { + it('initializes with defaults when no remote or persisted flags exist', () => { const { controller } = createController({ defaultFeatureFlags: { defaultFlag: 'defaultValue', @@ -1809,15 +1673,13 @@ describe('RemoteFeatureFlagController', () => { }, }); - await controller.init(); - expect(controller.state.remoteFeatureFlags).toStrictEqual({ defaultFlag: 'defaultValue', anotherDefault: false, }); }); - it('applies precedence of override over remote over default', async () => { + it('applies precedence of override over remote over default', () => { const { controller } = createController({ state: { remoteFeatureFlags: { @@ -1834,8 +1696,6 @@ describe('RemoteFeatureFlagController', () => { }, }); - await controller.init(); - expect(controller.state.remoteFeatureFlags).toStrictEqual({ sharedFlag: 'overrideValue', remoteOnly: true, @@ -1914,272 +1774,6 @@ describe('RemoteFeatureFlagController', () => { }); }); - describe('init', () => { - it('keeps an explicitly targeted user in their group across a restart', async () => { - const targetedFlag = [ - { - name: 'qa', - scope: { type: 'threshold', value: 0 }, - value: 'qaValue', - metaMetricsIds: [MOCK_METRICS_ID], - }, - { name: 'rest', scope: { type: 'threshold', value: 1 }, value: 'rest' }, - ]; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { targetedFlag }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => MOCK_METRICS_ID, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - // Only explicit targeting can select the zero-threshold group. - expect(controller.state.remoteFeatureFlags.targetedFlag).toBe('qaValue'); - - const { controller: restartedController } = createController({ - state: controller.state, - getMetaMetricsId: () => MOCK_METRICS_ID, - }); - await restartedController.init(); - - expect(restartedController.state.remoteFeatureFlags.targetedFlag).toBe( - 'qaValue', - ); - expect( - restartedController.state.featureFlagThresholdGroups, - ).toStrictEqual({ targetedFlag: 'qa' }); - }); - - it('rebuilds the remote layer so a stale override value is not mistaken for it', async () => { - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { sharedFlag: 'fromServer' }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - messenger.call( - 'RemoteFeatureFlagController:setFlagOverride', - 'sharedFlag', - 'overridden', - ); - - const { controller: restartedController, messenger: restartedMessenger } = - createController({ state: controller.state }); - await restartedController.init(); - - restartedMessenger.call( - 'RemoteFeatureFlagController:removeFlagOverride', - 'sharedFlag', - ); - - expect(restartedController.state.remoteFeatureFlags).toStrictEqual({ - sharedFlag: 'fromServer', - }); - }); - - it('re-evaluates version gating against the current client version', async () => { - const versionedFlag = { - versions: { - '13.0.0': { enabled: false }, - '14.0.0': { enabled: true }, - }, - }; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { versionedFlag }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - clientVersion: '13.10.0', - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - expect(controller.state.remoteFeatureFlags.versionedFlag).toStrictEqual({ - enabled: false, - }); - - const { controller: upgradedController } = createController({ - state: controller.state, - clientVersion: '14.0.0', - }); - await upgradedController.init(); - - expect( - upgradedController.state.remoteFeatureFlags.versionedFlag, - ).toStrictEqual({ enabled: true }); - }); - - it('keeps local overrides on top of the rebuilt layer', async () => { - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { remoteFlag: 'fromServer' }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - messenger.call( - 'RemoteFeatureFlagController:setFlagOverride', - 'remoteFlag', - 'overridden', - ); - - const { controller: restartedController } = createController({ - state: controller.state, - defaultFeatureFlags: { defaultOnly: 'fromDefaults' }, - }); - await restartedController.init(); - - expect(restartedController.state.remoteFeatureFlags).toStrictEqual({ - defaultOnly: 'fromDefaults', - remoteFlag: 'overridden', - }); - }); - - it('carries over the previous session flags when there are no persisted raw flags', async () => { - const { controller } = createController({ - state: { - remoteFeatureFlags: { carriedOver: 'fromLastSession' }, - rawRemoteFeatureFlags: {}, - cacheTimestamp: 123456789, - }, - }); - - await controller.init(); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - carriedOver: 'fromLastSession', - }); - expect(controller.state.cacheTimestamp).toBe(123456789); - }); - - it('carries over the previous session flags when raw flags are absent from persisted state', async () => { - const { controller } = createController({ - state: { - remoteFeatureFlags: { carriedOver: 'fromLastSession' }, - rawRemoteFeatureFlags: undefined, - }, - }); - - await controller.init(); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - carriedOver: 'fromLastSession', - }); - }); - - it('layers defaults and overrides onto the carried over flags when there are no raw flags', async () => { - const { controller } = createController({ - state: { - remoteFeatureFlags: { - carriedOver: 'fromLastSession', - sharedFlag: 'fromLastSession', - }, - localOverrides: { sharedFlag: 'overridden' }, - rawRemoteFeatureFlags: {}, - }, - defaultFeatureFlags: { - defaultOnly: 'fromDefaults', - carriedOver: 'fromDefaults', - }, - }); - - await controller.init(); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - defaultOnly: 'fromDefaults', - carriedOver: 'fromLastSession', - sharedFlag: 'overridden', - }); - }); - - it('applies defaults and overrides on a fresh install with no persisted flags', async () => { - const { controller } = createController({ - state: { localOverrides: { sharedFlag: 'overridden' } }, - defaultFeatureFlags: { - defaultOnly: 'fromDefaults', - sharedFlag: 'fromDefaults', - }, - }); - - await controller.init(); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - defaultOnly: 'fromDefaults', - sharedFlag: 'overridden', - }); - }); - - it('leaves the merge to init rather than the constructor', () => { - const { controller } = createController({ - state: { - remoteFeatureFlags: { remoteFlag: 'fromLastSession' }, - localOverrides: { overrideFlag: 'overridden' }, - }, - defaultFeatureFlags: { defaultOnly: 'fromDefaults' }, - }); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - remoteFlag: 'fromLastSession', - }); - }); - - it('does not fetch, and leaves raw flags and the cache timestamp untouched', async () => { - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { remoteFlag: 'fromServer' }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - const { cacheTimestamp, rawRemoteFeatureFlags } = controller.state; - jest.mocked(clientConfigApiService.fetchRemoteFeatureFlags).mockClear(); - - await controller.init(); - - expect( - clientConfigApiService.fetchRemoteFeatureFlags, - ).not.toHaveBeenCalled(); - expect(controller.state.cacheTimestamp).toBe(cacheTimestamp); - expect(controller.state.rawRemoteFeatureFlags).toStrictEqual( - rawRemoteFeatureFlags, - ); - }); - - it('is safe to call more than once', async () => { - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: { remoteFlag: 'fromServer' }, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - await controller.init(); - await controller.init(); - - expect(controller.state.remoteFeatureFlags).toStrictEqual({ - remoteFlag: 'fromServer', - }); - }); - }); - describe('threshold cache cleanup', () => { it('removes stale threshold cache entries when flags are removed from server', async () => { jest.useRealTimers(); @@ -2205,7 +1799,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['flagA', 'flagB'], }); // Act - First update: both flags processed @@ -2336,7 +1929,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['persistentFlag'], }); // Act - Multiple updates with same flag @@ -2383,7 +1975,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['testFlag'], state: { thresholdCache: { [`${differentUserId}:oldFlag`]: 0.123, // Different user's cache @@ -2420,7 +2011,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['newFlag'], }); // Act - Process with empty cache @@ -2450,7 +2040,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['oldFlag', 'newFlag'], }); await messenger.call( @@ -2513,7 +2102,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => '', // Empty metaMetricsId - metaMetricsFlags: ['thresholdFlag'], }); // Act @@ -2544,7 +2132,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['feature:v2'], }); // Act @@ -2593,7 +2180,6 @@ describe('RemoteFeatureFlagController', () => { const { controller, messenger } = createController({ clientConfigApiService, getMetaMetricsId: () => MOCK_METRICS_ID, - metaMetricsFlags: ['flagA', 'flagB'], }); // Act - First update populates cache @@ -2701,43 +2287,6 @@ describe('RemoteFeatureFlagController', () => { } `); }); - - it.each(['includeInStateLogs', 'includeInDebugSnapshot'] as const)( - 'sends raw flags including metaMetricsIds to %s', - async (metadataProperty) => { - const rawFlags = { - testFlag: [ - { - name: 'qaGroup', - scope: { type: 'threshold', value: 0.0 }, - value: 'qa-value', - metaMetricsIds: [MOCK_METRICS_ID], - }, - ], - }; - const clientConfigApiService = buildClientConfigApiService({ - remoteFeatureFlags: rawFlags, - }); - const { controller, messenger } = createController({ - clientConfigApiService, - getMetaMetricsId: () => MOCK_METRICS_ID, - }); - - await messenger.call( - 'RemoteFeatureFlagController:updateRemoteFeatureFlags', - ); - - // These IDs identify QA and PM testers rather than the reporting user, - // and are broadcast to every client, so they are deliberately not - // redacted on the way out. - const derived = deriveStateFromMetadata( - controller.state, - controller.metadata, - metadataProperty, - ); - expect(derived.rawRemoteFeatureFlags).toStrictEqual(rawFlags); - }, - ); }); }); diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts index 55a87a6f1e5..8f1b2b8c055 100644 --- a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -159,6 +159,38 @@ function findExplicitIdMatch( return undefined; } +/** + * Returns a copy of `flags` with `metaMetricsIds` removed from every + * threshold entry. Used before persisting raw flags to state so that + * MetaMetrics IDs are never written to state logs or debug snapshots. + * + * @param flags - The raw feature flags object from the API. + * @returns A new object with the same structure but without any + * `metaMetricsIds` fields inside threshold entry arrays. + */ +function redactMetaMetricsIds(flags: FeatureFlags): FeatureFlags { + const result: FeatureFlags = {}; + for (const [name, value] of Object.entries(flags)) { + if (!Array.isArray(value)) { + result[name] = value; + continue; + } + result[name] = value.map((entry) => { + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) { + return entry; + } + const entryRecord = entry as Record; + if (entryRecord.metaMetricsIds === undefined) { + return entry; + } + const copy: Record = { ...entryRecord }; + delete copy.metaMetricsIds; + return copy as Json; + }); + } + return result; +} + /** * The RemoteFeatureFlagController manages the retrieval and caching of remote feature flags. * It fetches feature flags from a remote API, caches them, and provides methods to access @@ -180,15 +212,11 @@ export class RemoteFeatureFlagController extends BaseController< readonly #getMetaMetricsId: () => string; - readonly #getCanonicalProfileId: () => string; - - readonly #metaMetricsFlags: ReadonlySet; - readonly #clientVersion: SemVerVersion; readonly #defaultFeatureFlags: FeatureFlags; - #processedRemoteFeatureFlags: FeatureFlags; + #processedRemoteFeatureFlags: FeatureFlags = {}; /** * Constructs a new RemoteFeatureFlagController instance. @@ -200,8 +228,6 @@ export class RemoteFeatureFlagController extends BaseController< * @param options.fetchInterval - The interval in milliseconds before cached flags expire. Defaults to 1 day. * @param options.disabled - Determines if the controller should be disabled initially. Defaults to false. * @param options.getMetaMetricsId - Returns metaMetricsId. - * @param options.getCanonicalProfileId - Returns the canonical profile identifier used for threshold flags by default. Must return an empty string, rather than throwing, when the identifier is unavailable. - * @param options.metaMetricsFlags - Names of feature flags that should use MetaMetrics ID for threshold assignment. * @param options.clientVersion - The current client version for version-based feature flag filtering. Must be a valid 3-part SemVer version string. * @param options.prevClientVersion - The previous client version for feature flag cache invalidation. * @param options.defaultFeatureFlags - Client-side default feature flags used as the lowest-precedence layer under processed remote flags and local overrides. Not persisted. @@ -213,8 +239,6 @@ export class RemoteFeatureFlagController extends BaseController< fetchInterval = DEFAULT_CACHE_DURATION, disabled = false, getMetaMetricsId, - getCanonicalProfileId, - metaMetricsFlags = [], clientVersion, prevClientVersion, defaultFeatureFlags = {}, @@ -223,8 +247,6 @@ export class RemoteFeatureFlagController extends BaseController< state?: Partial; clientConfigApiService: AbstractClientConfigApiService; getMetaMetricsId: () => string; - getCanonicalProfileId: () => string; - metaMetricsFlags?: readonly string[]; fetchInterval?: number; disabled?: boolean; clientVersion: string; @@ -246,12 +268,30 @@ export class RemoteFeatureFlagController extends BaseController< isValidSemVerVersion(prevClientVersion) && prevClientVersion !== clientVersion; + const localOverrides = initialState.localOverrides ?? {}; + + // Rebuild the processed remote layer from last session's effective flags by + // stripping local overrides. + const processedRemoteFeatureFlags = { + ...initialState.remoteFeatureFlags, + }; + for (const [flagName, overrideValue] of Object.entries(localOverrides)) { + if (processedRemoteFeatureFlags[flagName] === overrideValue) { + delete processedRemoteFeatureFlags[flagName]; + } + } + super({ name: controllerName, metadata: remoteFeatureFlagControllerMetadata, messenger, state: { ...initialState, + remoteFeatureFlags: { + ...defaultFeatureFlags, + ...processedRemoteFeatureFlags, + ...localOverrides, + }, cacheTimestamp: hasClientVersionChanged ? 0 : initialState.cacheTimestamp, @@ -259,17 +299,11 @@ export class RemoteFeatureFlagController extends BaseController< }); this.#defaultFeatureFlags = defaultFeatureFlags; - // Last session's effective flags stand in for the remote layer until - // `init` re-derives it from the persisted raw flags, or a fetch replaces - // it. Overrides are layered on top rather than subtracted out, so a remote - // flag that happens to share an override's value is not lost. - this.#processedRemoteFeatureFlags = initialState.remoteFeatureFlags; + this.#processedRemoteFeatureFlags = processedRemoteFeatureFlags; this.#fetchInterval = fetchInterval; this.#disabled = disabled; this.#clientConfigApiService = clientConfigApiService; this.#getMetaMetricsId = getMetaMetricsId; - this.#getCanonicalProfileId = getCanonicalProfileId; - this.#metaMetricsFlags = new Set(metaMetricsFlags); this.#clientVersion = clientVersion; this.messenger.registerMethodActionHandlers( @@ -282,23 +316,17 @@ export class RemoteFeatureFlagController extends BaseController< * Computes effective feature flags with precedence: * defaults < processed remote < local overrides. * - * @param options - The layers to merge. Each defaults to the current layer. - * @param options.processedRemoteFeatureFlags - The processed remote feature - * flags. Defaults to the currently resolved remote layer. - * @param options.localOverrides - Local overrides. Defaults to current state - * overrides. + * @param processedRemote - The processed remote feature flags. + * @param localOverrides - Local overrides. Defaults to current state overrides. * @returns The effective feature flags. */ - #getEffectiveFeatureFlags({ - processedRemoteFeatureFlags = this.#processedRemoteFeatureFlags, - localOverrides = this.state.localOverrides, - }: { - processedRemoteFeatureFlags?: FeatureFlags; - localOverrides?: FeatureFlags; - } = {}): FeatureFlags { + #getEffectiveFeatureFlags( + processedRemote: FeatureFlags, + localOverrides: FeatureFlags = this.state.localOverrides ?? {}, + ): FeatureFlags { return { ...this.#defaultFeatureFlags, - ...processedRemoteFeatureFlags, + ...processedRemote, ...localOverrides, }; } @@ -343,61 +371,59 @@ export class RemoteFeatureFlagController extends BaseController< } /** - * Computes the effective feature flags, re-deriving the remote layer from the - * raw flags already in state. Threshold selection needs to await a hash and - * so cannot run in the constructor, which is why this cannot be part of - * construction. Clients must call this once after constructing the - * controller. + * Updates the controller's state with new feature flags and resets the cache timestamp. * - * When there are no persisted raw flags, as on a fresh install or for state - * persisted before raw flags were stored, the previous session's flags stand - * in for the remote layer so that nothing is lost. + * @param remoteFeatureFlags - The new feature flags to cache. */ - async init(): Promise { - const { rawRemoteFeatureFlags } = this.state; - const hasRawRemoteFeatureFlags = - rawRemoteFeatureFlags && Object.keys(rawRemoteFeatureFlags).length > 0; + async #updateCache(remoteFeatureFlags: FeatureFlags): Promise { + const { + processedFlags, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, + } = await this.#processRemoteFeatureFlags(remoteFeatureFlags); - const resolved = hasRawRemoteFeatureFlags - ? await this.#processRemoteFeatureFlags(rawRemoteFeatureFlags) - : undefined; + const metaMetricsId = this.#getMetaMetricsId(); + const currentFlagNames = Object.keys(remoteFeatureFlags); - this.#processedRemoteFeatureFlags = - resolved?.processedFlags ?? this.state.remoteFeatureFlags; + // Build updated threshold cache + const updatedThresholdCache = { ...(this.state.thresholdCache ?? {}) }; - this.update(() => { - return { - ...this.state, - remoteFeatureFlags: this.#getEffectiveFeatureFlags(), - ...(resolved && { - thresholdCache: resolved.thresholdCache, - featureFlagThresholdGroups: resolved.featureFlagThresholdGroups, - }), - }; - }); - } + // Apply new thresholds + for (const [cacheKey, threshold] of Object.entries(thresholdCacheUpdates)) { + updatedThresholdCache[cacheKey] = threshold; + } - /** - * Updates the controller's state with new feature flags and resets the cache timestamp. - * - * @param remoteFeatureFlags - The new feature flags to cache. - */ - async #updateCache(remoteFeatureFlags: FeatureFlags): Promise { - const resolved = await this.#processRemoteFeatureFlags(remoteFeatureFlags); + // Clean up stale entries + for (const cacheKey of Object.keys(updatedThresholdCache)) { + const [cachedMetaMetricsId, ...cachedFlagNameParts] = cacheKey.split(':'); + const cachedFlagName = cachedFlagNameParts.join(':'); + if ( + cachedMetaMetricsId === metaMetricsId && + !currentFlagNames.includes(cachedFlagName) + ) { + delete updatedThresholdCache[cacheKey]; + } + } - this.#processedRemoteFeatureFlags = resolved.processedFlags; + // Strip metaMetricsIds from processed flags so they never appear in + // remoteFeatureFlags state or #processedRemoteFeatureFlags. Arrays that + // were preserved as-is (e.g. when metaMetricsId is missing) would + // otherwise leak explicit-targeting IDs into diagnostics. + const redactedProcessedFlags = redactMetaMetricsIds(processedFlags); // Single state update with all changes batched together + this.#processedRemoteFeatureFlags = redactedProcessedFlags; + this.update(() => { return { ...this.state, - remoteFeatureFlags: this.#getEffectiveFeatureFlags({ - processedRemoteFeatureFlags: resolved.processedFlags, - }), - rawRemoteFeatureFlags: remoteFeatureFlags, + remoteFeatureFlags: this.#getEffectiveFeatureFlags( + redactedProcessedFlags, + ), + rawRemoteFeatureFlags: redactMetaMetricsIds(remoteFeatureFlags), cacheTimestamp: Date.now(), - thresholdCache: resolved.thresholdCache, - featureFlagThresholdGroups: resolved.featureFlagThresholdGroups, + thresholdCache: updatedThresholdCache, + featureFlagThresholdGroups: featureFlagThresholdGroupUpdates, }; }); } @@ -416,39 +442,15 @@ export class RemoteFeatureFlagController extends BaseController< return getVersionData(flagValue, this.#clientVersion); } - /** - * Selects the identifier used to bucket a threshold flag. Flags named in - * `metaMetricsFlags` segment by MetaMetrics ID; all others segment by - * canonical profile ID. - * - * @param featureFlagName - The name of the feature flag being processed. - * @returns The segmentation identifier, which may be empty when unavailable. - */ - #getSegmentationId(featureFlagName: string): string { - if (this.#metaMetricsFlags.has(featureFlagName)) { - return this.#getMetaMetricsId(); - } - return this.#getCanonicalProfileId(); - } - - /** - * Resolves raw feature flags into the values that apply to this client and - * user, selecting version and threshold entries and reconciling the - * threshold cache against the flags the server currently serves. - * - * @param remoteFeatureFlags - The unprocessed feature flags. - * @returns The processed flags, the updated threshold cache, and the - * selected threshold group names. - */ async #processRemoteFeatureFlags(remoteFeatureFlags: FeatureFlags): Promise<{ processedFlags: FeatureFlags; - thresholdCache: Record; - featureFlagThresholdGroups: Record; + thresholdCacheUpdates: Record; + featureFlagThresholdGroupUpdates: Record; }> { const processedFlags: FeatureFlags = {}; const metaMetricsId = this.#getMetaMetricsId(); const thresholdCacheUpdates: Record = {}; - const featureFlagThresholdGroups: Record = {}; + const featureFlagThresholdGroupUpdates: Record = {}; for (const [ remoteFeatureFlagName, @@ -473,34 +475,34 @@ export class RemoteFeatureFlagController extends BaseController< continue; } + // Skip threshold processing if metaMetricsId is not available + if (!metaMetricsId) { + // Preserve array as-is when user hasn't opted into MetaMetrics + processedFlags[remoteFeatureFlagName] = processedValue; + continue; + } + // Explicit-ID matching: check before hash-based threshold, bypasses cache const normalizedMetaMetricsId = metaMetricsId.trim().toLowerCase(); - const explicitMatch = normalizedMetaMetricsId - ? findExplicitIdMatch(processedValue, normalizedMetaMetricsId) - : undefined; + const explicitMatch = findExplicitIdMatch( + processedValue, + normalizedMetaMetricsId, + ); if (explicitMatch) { processedValue = explicitMatch.value; if (explicitMatch.name) { - featureFlagThresholdGroups[remoteFeatureFlagName] = + featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = explicitMatch.name; } } else { - const segmentationId = this.#getSegmentationId(remoteFeatureFlagName); - - if (!segmentationId) { - processedFlags[remoteFeatureFlagName] = processedValue; - continue; - } - // Fall back to hash-based threshold selection with cache - const cacheKey = - `${segmentationId}:${remoteFeatureFlagName}` as const; + const cacheKey = `${metaMetricsId}:${remoteFeatureFlagName}` as const; let thresholdValue = this.state.thresholdCache?.[cacheKey]; if (thresholdValue === undefined) { thresholdValue = await calculateThresholdForFlag( - segmentationId, + metaMetricsId, remoteFeatureFlagName, ); @@ -522,7 +524,7 @@ export class RemoteFeatureFlagController extends BaseController< if (selectedGroup) { processedValue = selectedGroup.value; if (selectedGroup.name) { - featureFlagThresholdGroups[remoteFeatureFlagName] = + featureFlagThresholdGroupUpdates[remoteFeatureFlagName] = selectedGroup.name; } } @@ -532,32 +534,10 @@ export class RemoteFeatureFlagController extends BaseController< processedFlags[remoteFeatureFlagName] = processedValue; } - const thresholdCache = { - ...this.state.thresholdCache, - ...thresholdCacheUpdates, - }; - - // Drop cached thresholds for flags this user is no longer served, under - // either identifier they may have been bucketed by. - const canonicalProfileId = this.#getCanonicalProfileId(); - const currentFlagNames = Object.keys(remoteFeatureFlags); - for (const cacheKey of Object.keys(thresholdCache)) { - const [cachedSegmentationId, ...cachedFlagNameParts] = - cacheKey.split(':'); - const cachedFlagName = cachedFlagNameParts.join(':'); - if ( - (cachedSegmentationId === metaMetricsId || - cachedSegmentationId === canonicalProfileId) && - !currentFlagNames.includes(cachedFlagName) - ) { - delete thresholdCache[cacheKey]; - } - } - return { processedFlags, - thresholdCache, - featureFlagThresholdGroups, + thresholdCacheUpdates, + featureFlagThresholdGroupUpdates, }; } @@ -591,7 +571,10 @@ export class RemoteFeatureFlagController extends BaseController< return { ...this.state, localOverrides, - remoteFeatureFlags: this.#getEffectiveFeatureFlags({ localOverrides }), + remoteFeatureFlags: this.#getEffectiveFeatureFlags( + this.#processedRemoteFeatureFlags, + localOverrides, + ), }; }); } @@ -609,9 +592,10 @@ export class RemoteFeatureFlagController extends BaseController< return { ...this.state, localOverrides: newLocalOverrides, - remoteFeatureFlags: this.#getEffectiveFeatureFlags({ - localOverrides: newLocalOverrides, - }), + remoteFeatureFlags: this.#getEffectiveFeatureFlags( + this.#processedRemoteFeatureFlags, + newLocalOverrides, + ), }; }); } @@ -624,9 +608,10 @@ export class RemoteFeatureFlagController extends BaseController< return { ...this.state, localOverrides: {}, - remoteFeatureFlags: this.#getEffectiveFeatureFlags({ - localOverrides: {}, - }), + remoteFeatureFlags: this.#getEffectiveFeatureFlags( + this.#processedRemoteFeatureFlags, + {}, + ), }; }); } diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts index 647020e0010..aa11941462f 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts @@ -124,15 +124,15 @@ describe('user-segmentation-utils', () => { expect(threshold).toBeLessThanOrEqual(1); }); - it('throws error when segmentation ID is empty', async () => { + it('throws error when metaMetricsId is empty', async () => { // Arrange - const emptySegmentationId = ''; + const emptyMetaMetricsId = ''; const flagName = 'testFlag'; // Act & Assert await expect( - calculateThresholdForFlag(emptySegmentationId, flagName), - ).rejects.toThrow('Segmentation ID cannot be empty'); + calculateThresholdForFlag(emptyMetaMetricsId, flagName), + ).rejects.toThrow('MetaMetrics ID cannot be empty'); }); it('throws error when featureFlagName is empty', async () => { diff --git a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts index b85d9a8ab5d..e41a721e4b0 100644 --- a/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts +++ b/packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts @@ -22,27 +22,27 @@ const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT; /** * Calculates a deterministic threshold value between 0 and 1 for A/B testing. - * This function hashes the segmentation ID combined with the feature flag name + * This function hashes the user's MetaMetrics ID combined with the feature flag name * to ensure consistent group assignment across sessions while varying across different flags. * - * @param segmentationId - The identifier used for threshold segmentation (must be non-empty) + * @param metaMetricsId - The user's MetaMetrics ID (must be non-empty) * @param featureFlagName - The feature flag name to create unique threshold per flag * @returns A promise that resolves to a number between 0 and 1 - * @throws Error if segmentationId is empty + * @throws Error if metaMetricsId is empty */ export async function calculateThresholdForFlag( - segmentationId: string, + metaMetricsId: string, featureFlagName: string, ): Promise { - if (!segmentationId) { - throw new Error('Segmentation ID cannot be empty'); + if (!metaMetricsId) { + throw new Error('MetaMetrics ID cannot be empty'); } if (!featureFlagName) { throw new Error('Feature flag name cannot be empty'); } - const seed = segmentationId + featureFlagName; + const seed = metaMetricsId + featureFlagName; // Hash the combined seed const encoder = new TextEncoder(); diff --git a/packages/sample-controllers/CHANGELOG.md b/packages/sample-controllers/CHANGELOG.md index d70e1da8ad9..5b00cb05fe7 100644 --- a/packages/sample-controllers/CHANGELOG.md +++ b/packages/sample-controllers/CHANGELOG.md @@ -9,7 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - Bump `@metamask/network-controller` from `^35.0.0` to `^35.0.1` ([#9758](https://github.com/MetaMask/core/pull/9758)) - Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) diff --git a/packages/sample-controllers/package.json b/packages/sample-controllers/package.json index 7648669388f..d9dcd1e9c04 100644 --- a/packages/sample-controllers/package.json +++ b/packages/sample-controllers/package.json @@ -61,7 +61,7 @@ "@metamask/network-controller": "^35.0.1", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/sentinel-api-service/CHANGELOG.md b/packages/sentinel-api-service/CHANGELOG.md index eb9c3907266..3f97d644ea5 100644 --- a/packages/sentinel-api-service/CHANGELOG.md +++ b/packages/sentinel-api-service/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/superstruct` from `^3.1.0` to `^3.4.1` ([#9754](https://github.com/MetaMask/core/pull/9754)) -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [1.0.0] diff --git a/packages/sentinel-api-service/package.json b/packages/sentinel-api-service/package.json index e4abdc24c33..f1e43ce619e 100644 --- a/packages/sentinel-api-service/package.json +++ b/packages/sentinel-api-service/package.json @@ -60,7 +60,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/shield-controller/CHANGELOG.md b/packages/shield-controller/CHANGELOG.md index ada0e295d26..89f862cb55a 100644 --- a/packages/shield-controller/CHANGELOG.md +++ b/packages/shield-controller/CHANGELOG.md @@ -10,7 +10,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) ## [6.0.0] diff --git a/packages/shield-controller/package.json b/packages/shield-controller/package.json index 8b54200b82b..1ca1e97ab73 100644 --- a/packages/shield-controller/package.json +++ b/packages/shield-controller/package.json @@ -63,7 +63,7 @@ "@metamask/signature-controller": "^39.2.9", "@metamask/transaction-controller": "^69.5.2", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16", + "@tanstack/query-core": "^4.43.0", "cockatiel": "^3.1.2" }, "devDependencies": { diff --git a/packages/shield-controller/src/shield-api-service.test.ts b/packages/shield-controller/src/shield-api-service.test.ts index 4192d8619db..cc3a309c150 100644 --- a/packages/shield-controller/src/shield-api-service.test.ts +++ b/packages/shield-controller/src/shield-api-service.test.ts @@ -267,19 +267,20 @@ describe('ShieldApiService', () => { const txMeta = generateMockTxMeta(); + let callCount = 0; const startTime = 1000; const expectedLatency = pollInterval + 50; - // Advance the clock only once polling has completed (the third fetch, which - // returns the coverage result). Keying off fetch progress rather than the - // number of `Date.now()` calls keeps this robust to how many times - // query-core reads the clock internally. - const nowSpy = jest - .spyOn(Date, 'now') - .mockImplementation(() => - fetchMock.mock.calls.length >= 3 - ? startTime + expectedLatency - : startTime, - ); + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => { + callCount += 1; + // `fetchQuery` during init may call `Date.now()` before polling latency is measured. + if (callCount <= 1) { + return startTime; + } + if (callCount === 2) { + return startTime; + } + return startTime + expectedLatency; + }); const coverageResult = await service.checkCoverage({ txMeta }); diff --git a/packages/shield-controller/src/shield-api-service.ts b/packages/shield-controller/src/shield-api-service.ts index 420fb9e3e82..4820283d75a 100644 --- a/packages/shield-controller/src/shield-api-service.ts +++ b/packages/shield-controller/src/shield-api-service.ts @@ -324,7 +324,7 @@ export class ShieldApiService extends BaseDataService< req.status, ], staleTime: 0, - gcTime: 0, + cacheTime: 0, queryFn: async () => { const res = await this.#fetch( `${this.#baseUrl}/v1/signature/coverage/log`, @@ -380,7 +380,7 @@ export class ShieldApiService extends BaseDataService< req.status, ], staleTime: 0, - gcTime: 0, + cacheTime: 0, queryFn: async () => { const res = await this.#fetch( `${this.#baseUrl}/v1/transaction/coverage/log`, @@ -420,7 +420,7 @@ export class ShieldApiService extends BaseDataService< return await this.fetchQuery({ queryKey: [`${this.name}:initCoverageCheck`, path, requestId], staleTime: 0, - gcTime: 0, + cacheTime: 0, queryFn: async () => { const res = await this.#fetch(`${this.#baseUrl}/${path}`, { method: 'POST', diff --git a/packages/subscription-controller/CHANGELOG.md b/packages/subscription-controller/CHANGELOG.md index 90acc86bb20..a30b8011da3 100644 --- a/packages/subscription-controller/CHANGELOG.md +++ b/packages/subscription-controller/CHANGELOG.md @@ -9,44 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Bump `@tanstack/query-core` from `^4.43.0` to `^5.62.16` ([#9712](https://github.com/MetaMask/core/pull/9712)) - -## [8.0.0] - -### Added - -- Add multi-product and delegation-based crypto subscription types and pricing fields. ([#9866](https://github.com/MetaMask/core/pull/9866)) - - `PRODUCT_TYPES.MONEY_ACCOUNT_PLUS` - - `CRYPTO_AUTH_METHODS` (`erc20_approval`, `delegation`) and `CryptoAuthMethod` - - `StartErc20CryptoSubscriptionRequest` and `StartDelegationCryptoSubscriptionRequest` - - `PricingCardPaymentMethod` and `PricingCryptoPaymentMethod` variants, with optional `products` and (crypto only) `cryptoAuthMethod` - - Optional `ChainPaymentInfo.delegateAddress` - - `SpotTokenPaymentInfo` and `VaultTokenPaymentInfo` variants, with optional `sources`; vault shares require `accountantAddress` - -### Changed - -- **BREAKING:** Model `PricingPaymentMethod` as a discriminated union of card vs crypto. `chains` and `cryptoAuthMethod` exist only on the crypto variant; narrow with `type === 'crypto'` before reading them. ([#9866](https://github.com/MetaMask/core/pull/9866)) -- **BREAKING:** Model `TokenPaymentInfo` as a discriminated union of vault vs spot. `accountantAddress` is required when `isVaultShare` is true and is not present on spot tokens; narrow with `isVaultShare === true` before reading `accountantAddress`. ([#9866](https://github.com/MetaMask/core/pull/9866)) -- **BREAKING:** Rename `startShieldSubscriptionWithCard` to `startSubscriptionWithCard`. ([#9866](https://github.com/MetaMask/core/pull/9866)) - - Rename `SubscriptionController.startShieldSubscriptionWithCard` to `startSubscriptionWithCard`. - - Rename the messenger action `SubscriptionController:startShieldSubscriptionWithCard` to `SubscriptionController:startSubscriptionWithCard`. - - Rename the exported action type `SubscriptionControllerStartShieldSubscriptionWithCardAction` to `SubscriptionControllerStartSubscriptionWithCardAction`. -- **BREAKING:** Rename `submitShieldSubscriptionCryptoApproval` to `submitSubscriptionCryptoApproval` and take a request object instead of positional arguments. ([#9866](https://github.com/MetaMask/core/pull/9866)) - - Rename `SubscriptionController.submitShieldSubscriptionCryptoApproval` to `submitSubscriptionCryptoApproval`. - - Rename the messenger action `SubscriptionController:submitShieldSubscriptionCryptoApproval` to `SubscriptionController:submitSubscriptionCryptoApproval`. - - Rename the exported action type `SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction` to `SubscriptionControllerSubmitSubscriptionCryptoApprovalAction`. - - Callers pass `{ productType, txMeta, isSponsored?, rewardAccountId? }` (`SubmitSubscriptionCryptoApprovalRequest`). - - This handler is Shield ERC-20 approve only. `productType` is typed as `typeof PRODUCT_TYPES.SHIELD` (not `ProductType`); `txMeta.type` must be `TransactionType.shieldSubscriptionApprove`. Other products should use `startSubscriptionWithCrypto`. -- **BREAKING:** Make `TokenPaymentInfo.conversionRate` optional. Consumers that access `.conversionRate.usd` without optional chaining will fail typecheck. ([#9866](https://github.com/MetaMask/core/pull/9866)) -- **BREAKING:** Change `SubscriptionControllerState.lastSelectedPaymentMethod` from `Record` to `Partial>`. Product keys may be absent; consumers must handle missing entries. ([#9866](https://github.com/MetaMask/core/pull/9866)) -- **BREAKING:** `SubscriptionController.cacheLastSelectedPaymentMethod` now takes a request object instead of positional arguments. ([#9866](https://github.com/MetaMask/core/pull/9866)) - - Callers pass `{ product, paymentMethod }` (`CacheLastSelectedPaymentMethodRequest`). -- **BREAKING:** Model `StartCryptoSubscriptionRequest` as a discriminated union of ERC-20 approval vs delegation. ([#9866](https://github.com/MetaMask/core/pull/9866)) - - ERC-20: required `rawTransaction`; optional `cryptoAuthMethod: 'erc20_approval'` (the default when omitted). - - Delegation: required `cryptoAuthMethod: 'delegation'` and `delegationHash`. - - Combining or omitting both auth fields is a type error. Runtime validation in `startSubscriptionWithCrypto` remains for unsound callers. - - New exports: `StartErc20CryptoSubscriptionRequest`, `StartDelegationCryptoSubscriptionRequest`. -- Generalize subscription controller flows for multiple products: product-scoped crypto payment-method lookup, and trial requests derived from `trialPeriodDays` plus `trialedProducts`. ([#9866](https://github.com/MetaMask/core/pull/9866)) - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) ## [7.0.0] @@ -444,8 +406,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) - Bump `@metamask/utils` from `^11.4.2` to `^11.8.0` ([#6588](https://github.com/MetaMask/core/pull/6588)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@8.0.0...HEAD -[8.0.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@7.0.0...@metamask/subscription-controller@8.0.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@7.0.0...HEAD [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@6.2.2...@metamask/subscription-controller@7.0.0 [6.2.2]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@6.2.1...@metamask/subscription-controller@6.2.2 [6.2.1]: https://github.com/MetaMask/core/compare/@metamask/subscription-controller@6.2.0...@metamask/subscription-controller@6.2.1 diff --git a/packages/subscription-controller/package.json b/packages/subscription-controller/package.json index 1bacb93b343..ebbaefeb4b4 100644 --- a/packages/subscription-controller/package.json +++ b/packages/subscription-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/subscription-controller", - "version": "8.0.0", + "version": "7.0.0", "description": "Handle user subscription", "keywords": [ "Ethereum", @@ -64,7 +64,7 @@ "@metamask/superstruct": "^3.4.1", "@metamask/transaction-controller": "^69.5.2", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16", + "@tanstack/query-core": "^4.43.0", "bignumber.js": "^9.1.2" }, "devDependencies": { diff --git a/packages/subscription-controller/src/SubscriptionController-method-action-types.ts b/packages/subscription-controller/src/SubscriptionController-method-action-types.ts index 7e96ff6d486..d2ad7f4c3d9 100644 --- a/packages/subscription-controller/src/SubscriptionController-method-action-types.ts +++ b/packages/subscription-controller/src/SubscriptionController-method-action-types.ts @@ -52,64 +52,29 @@ export type SubscriptionControllerUnCancelSubscriptionAction = { handler: SubscriptionController['unCancelSubscription']; }; -/** - * Starts a card-paid subscription checkout session for the requested products - * (e.g. Shield or Money Account Plus). - * - * `isTrialRequested` on the request is ignored and overwritten from pricing - * (`trialPeriodDays > 0`) and `trialedProducts`. - * - * @param request - The start subscription request. - * @returns The checkout session response. - */ -export type SubscriptionControllerStartSubscriptionWithCardAction = { - type: `SubscriptionController:startSubscriptionWithCard`; - handler: SubscriptionController['startSubscriptionWithCard']; +export type SubscriptionControllerStartShieldSubscriptionWithCardAction = { + type: `SubscriptionController:startShieldSubscriptionWithCard`; + handler: SubscriptionController['startShieldSubscriptionWithCard']; }; -/** - * Starts a crypto-paid subscription for the requested products - * (e.g. Shield or Money Account Plus). Unlike card checkout, this - * creates the subscription immediately, so local state is refreshed - * afterwards. - * - * `isTrialRequested` on the request is ignored and overwritten from pricing - * (`trialPeriodDays > 0`) and `trialedProducts`. - * - * @param request - The start crypto subscription request. - * @returns The start crypto subscription response. - * @throws If `products` is empty. - */ export type SubscriptionControllerStartSubscriptionWithCryptoAction = { type: `SubscriptionController:startSubscriptionWithCrypto`; handler: SubscriptionController['startSubscriptionWithCrypto']; }; /** - * Submits a Shield ERC-20 crypto approval transaction to start or update a - * crypto subscription. + * Handles shield subscription crypto approval transactions. * - * This handler is Shield / `TransactionType.shieldSubscriptionApprove` only. - * Delegation-based products (e.g. Money Account) must call - * `startSubscriptionWithCrypto` instead. - * - * @param request - The crypto approval request. - * @param request.productType - The subscription product. Typed as - * `typeof PRODUCT_TYPES.SHIELD` only at the moment (future might support more - * product). - * @param request.txMeta - The transaction metadata. Must have type - * `TransactionType.shieldSubscriptionApprove`. - * @param request.isSponsored - Whether the transaction is sponsored. - * @param request.rewardAccountId - The account ID of the reward subscription - * to link. - * @throws If `productType` is not Shield or `txMeta.type` is not - * `shieldSubscriptionApprove`. + * @param txMeta - The transaction metadata. + * @param isSponsored - Whether the transaction is sponsored. + * @param rewardAccountId - The account ID of the reward subscription to link to the shield subscription. * @returns void */ -export type SubscriptionControllerSubmitSubscriptionCryptoApprovalAction = { - type: `SubscriptionController:submitSubscriptionCryptoApproval`; - handler: SubscriptionController['submitSubscriptionCryptoApproval']; -}; +export type SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction = + { + type: `SubscriptionController:submitShieldSubscriptionCryptoApproval`; + handler: SubscriptionController['submitShieldSubscriptionCryptoApproval']; + }; /** * Get transaction params to create crypto approve transaction for subscription payment @@ -144,12 +109,12 @@ export type SubscriptionControllerGetBillingPortalUrlAction = { /** * Cache the last selected payment method for a specific product. * - * @param request - The request object. - * @param request.product - The product to cache the payment method for. - * @param request.paymentMethod - The payment method to cache. - * @param request.paymentMethod.type - The type of the payment method. - * @param request.paymentMethod.paymentTokenAddress - The payment token address. - * @param request.paymentMethod.plan - The plan of the payment method. + * @param product - The product to cache the payment method for. + * @param paymentMethod - The payment method to cache. + * @param paymentMethod.type - The type of the payment method. + * @param paymentMethod.paymentTokenAddress - The payment token address. + * @param paymentMethod.plan - The plan of the payment method. + * @param paymentMethod.product - The product of the payment method. */ export type SubscriptionControllerCacheLastSelectedPaymentMethodAction = { type: `SubscriptionController:cacheLastSelectedPaymentMethod`; @@ -179,8 +144,7 @@ export type SubscriptionControllerClearLastSelectedPaymentMethodAction = { * recurringInterval: RecurringInterval.Month, * billingCycles: 1, * } - * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false if the chain does not support sponsorship or the user has already trialed - * @throws If the crypto payment method or chain is missing from pricing + * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false otherwise */ export type SubscriptionControllerSubmitSponsorshipIntentsAction = { type: `SubscriptionController:submitSponsorshipIntents`; @@ -278,9 +242,9 @@ export type SubscriptionControllerMethodActions = | SubscriptionControllerGetSubscriptionsEligibilitiesAction | SubscriptionControllerCancelSubscriptionAction | SubscriptionControllerUnCancelSubscriptionAction - | SubscriptionControllerStartSubscriptionWithCardAction + | SubscriptionControllerStartShieldSubscriptionWithCardAction | SubscriptionControllerStartSubscriptionWithCryptoAction - | SubscriptionControllerSubmitSubscriptionCryptoApprovalAction + | SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction | SubscriptionControllerGetCryptoApproveTransactionParamsAction | SubscriptionControllerUpdatePaymentMethodAction | SubscriptionControllerGetBillingPortalUrlAction diff --git a/packages/subscription-controller/src/SubscriptionController.test.ts b/packages/subscription-controller/src/SubscriptionController.test.ts index 178d09d0900..250a0e48ba6 100644 --- a/packages/subscription-controller/src/SubscriptionController.test.ts +++ b/packages/subscription-controller/src/SubscriptionController.test.ts @@ -31,7 +31,7 @@ import type { Subscription, PricingResponse, ProductPricing, - PricingCryptoPaymentMethod, + PricingPaymentMethod, StartCryptoSubscriptionRequest, StartCryptoSubscriptionResponse, UpdatePaymentMethodOpts, @@ -85,27 +85,6 @@ const MOCK_SUBSCRIPTION: Subscription = { cancelType: CANCEL_TYPES.ALLOWED_AT_PERIOD_END, }; -const MOCK_MONEY_ACCOUNT_SUBSCRIPTION: Subscription = { - ...MOCK_SUBSCRIPTION, - id: 'sub_money_account', - products: [ - { - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - }, - ], - paymentMethod: { - type: PAYMENT_TYPES.byCrypto, - crypto: { - payerAddress: '0x1234567890123456789012345678901234567890', - chainId: '0x8f', - tokenSymbol: 'pvmUSD', - }, - }, -}; - const MOCK_PRODUCT_PRICE: ProductPricing = { name: PRODUCT_TYPES.SHIELD, prices: [ @@ -114,7 +93,7 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { currency: 'usd', unitAmount: 900, unitDecimals: 2, - trialPeriodDays: 14, + trialPeriodDays: 0, minBillingCycles: 12, minBillingCyclesForBalance: 1, }, @@ -130,35 +109,12 @@ const MOCK_PRODUCT_PRICE: ProductPricing = { ], }; -const MOCK_PRODUCT_PRICE_WITHOUT_TRIAL: ProductPricing = { - ...MOCK_PRODUCT_PRICE, - prices: MOCK_PRODUCT_PRICE.prices.map((price) => ({ - ...price, - trialPeriodDays: 0, - })), -}; - -const MOCK_MONEY_ACCOUNT_PRODUCT_PRICE: ProductPricing = { - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - prices: [ - { - interval: RECURRING_INTERVALS.month, - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - trialPeriodDays: 0, - minBillingCycles: 12, - minBillingCyclesForBalance: 1, - }, - ], -}; - -const MOCK_PRICING_PAYMENT_METHOD: PricingCryptoPaymentMethod = { +const MOCK_PRICING_PAYMENT_METHOD: PricingPaymentMethod = { type: PAYMENT_TYPES.byCrypto, chains: [ { chainId: '0x1', - paymentAddress: '0x00000000000000000000000000000000000000a2', + paymentAddress: '0xspender', isSponsorshipSupported: true, tokens: [ { @@ -183,12 +139,6 @@ const MOCK_GET_SUBSCRIPTIONS_RESPONSE = { trialedProducts: [], }; -const MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE = { - customerId: 'cus_1', - subscriptions: [] as Subscription[], - trialedProducts: [] as ProductType[], -}; - const MOCK_COHORTS = [ { cohort: 'post_tx', @@ -816,46 +766,6 @@ describe('SubscriptionController', () => { }, ); }); - - it('should fetch and store active Shield and Money Account subscriptions together', async () => { - await withController( - async ({ controller, rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION, MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }); - - const result = await rootMessenger.call( - 'SubscriptionController:getSubscriptions', - ); - - expect(result).toStrictEqual([ - MOCK_SUBSCRIPTION, - MOCK_MONEY_ACCOUNT_SUBSCRIPTION, - ]); - expect(controller.state.subscriptions).toStrictEqual([ - MOCK_SUBSCRIPTION, - MOCK_MONEY_ACCOUNT_SUBSCRIPTION, - ]); - expect(controller.state.trialedProducts).toStrictEqual([ - PRODUCT_TYPES.SHIELD, - ]); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.SHIELD, - ), - ).toStrictEqual(MOCK_SUBSCRIPTION); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - ), - ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); - }, - ); - }); }); describe('getSubscriptionByProduct', () => { @@ -889,146 +799,6 @@ describe('SubscriptionController', () => { }); }); - describe('multi-product subscriptions', () => { - it('should hold active Shield and Money Account subscriptions simultaneously', async () => { - await withController( - { - state: { - subscriptions: [MOCK_SUBSCRIPTION, MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - trialedProducts: [PRODUCT_TYPES.SHIELD], - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xmoneytoken', - paymentTokenSymbol: 'pvmUSD', - plan: RECURRING_INTERVALS.month, - cryptoAuthMethod: 'delegation', - }, - }, - }, - }, - async ({ controller, rootMessenger }) => { - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.SHIELD, - ), - ).toStrictEqual(MOCK_SUBSCRIPTION); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - ), - ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); - expect(controller.state.subscriptions).toHaveLength(2); - expect(controller.state.trialedProducts).toStrictEqual([ - PRODUCT_TYPES.SHIELD, - ]); - expect( - controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], - ).toBeDefined(); - expect( - controller.state.lastSelectedPaymentMethod?.[ - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS - ], - ).toBeDefined(); - }, - ); - }); - - it('should allow starting Money Account card checkout while Shield is active', async () => { - const checkoutResponse = { - checkoutSessionUrl: 'https://checkout.example.com/money-account', - }; - - await withController( - { - state: { - subscriptions: [MOCK_SUBSCRIPTION], - pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - mockService.startSubscriptionWithCard.mockResolvedValue( - checkoutResponse, - ); - - const result = await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }, - ); - - expect(result).toStrictEqual(checkoutResponse); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }); - }, - ); - }); - - it('should sync lastSubscription from API without using it as active product lookup', async () => { - const canceledShieldSubscription = { - ...MOCK_SUBSCRIPTION, - status: SUBSCRIPTION_STATUSES.canceled, - }; - - await withController( - { - state: { - subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - lastSubscription: undefined, - }, - }, - async ({ controller, rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - trialedProducts: [], - lastSubscription: canceledShieldSubscription, - }); - - await rootMessenger.call('SubscriptionController:getSubscriptions'); - - expect(controller.state.lastSubscription).toStrictEqual( - canceledShieldSubscription, - ); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - ), - ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.SHIELD, - ), - ).toBeUndefined(); - }, - ); - }); - }); - describe('cancelSubscription', () => { it('should cancel subscription successfully', async () => { const mockSubscription2 = { ...MOCK_SUBSCRIPTION, id: 'sub_2' }; @@ -1235,7 +1005,7 @@ describe('SubscriptionController', () => { }); }); - describe('startSubscriptionWithCard', () => { + describe('startShieldSubscriptionWithCard', () => { const MOCK_START_SUBSCRIPTION_RESPONSE = { checkoutSessionUrl: 'https://checkout.example.com/session/123', }; @@ -1245,19 +1015,15 @@ describe('SubscriptionController', () => { { state: { subscriptions: [], - pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); mockService.startSubscriptionWithCard.mockResolvedValue( MOCK_START_SUBSCRIPTION_RESPONSE, ); const result = await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', + 'SubscriptionController:startShieldSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1283,15 +1049,9 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - await expect( rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', + 'SubscriptionController:startShieldSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1313,13 +1073,9 @@ describe('SubscriptionController', () => { { state: { subscriptions: [], - pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); const errorMessage = 'Failed to start subscription'; mockService.startSubscriptionWithCard.mockRejectedValue( new SubscriptionServiceError(errorMessage), @@ -1327,7 +1083,7 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', + 'SubscriptionController:startShieldSubscriptionWithCard', { products: [PRODUCT_TYPES.SHIELD], isTrialRequested: true, @@ -1344,1513 +1100,380 @@ describe('SubscriptionController', () => { }, ); }); + }); - it('overwrites client-supplied isTrialRequested from pricing and trialedProducts', async () => { + describe('startCryptoSubscription', () => { + it('should start crypto subscription successfully when user is not subscribed', async () => { await withController( { state: { subscriptions: [], - trialedProducts: [], - pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); + const request: StartCryptoSubscriptionRequest = { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: false, + recurringInterval: RECURRING_INTERVALS.month, + billingCycles: 3, + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + rawTransaction: '0xdeadbeef', + }; - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }, + const response: StartCryptoSubscriptionResponse = { + subscriptionId: 'sub_crypto_123', + status: SUBSCRIPTION_STATUSES.active, + }; + + mockService.startSubscriptionWithCrypto.mockResolvedValue(response); + + const result = await rootMessenger.call( + 'SubscriptionController:startSubscriptionWithCrypto', + request, ); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - }); + expect(result).toStrictEqual(response); + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( + request, + ); }, ); }); + }); - it('does not request a trial when the product has already been trialed', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); + describe('startPolling', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - }, - ); - - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }); - }, - ); + afterEach(() => { + jest.useRealTimers(); }); - it('does not request a trial when pricing has no trial period', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [], - pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - }, - ); + it('should call getSubscriptions with the correct interval', async () => { + await withController(async ({ controller }) => { + const getSubscriptionsSpy = jest.spyOn(controller, 'getSubscriptions'); + controller.startPolling({}); + await jestAdvanceTime({ duration: 0 }); + expect(getSubscriptionsSpy).toHaveBeenCalledTimes(1); + }); + }); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }); - }, - ); + it('should call `triggerAccessTokenRefresh` when the state changes', async () => { + await withController(async ({ controller, mockService }) => { + mockService.getSubscriptions.mockResolvedValue( + MOCK_GET_SUBSCRIPTIONS_RESPONSE, + ); + const triggerAccessTokenRefreshSpy = jest.spyOn( + controller, + 'triggerAccessTokenRefresh', + ); + controller.startPolling({}); + await jestAdvanceTime({ duration: 0 }); + expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); + }); }); + }); - it('does not request a trial when Shield pricing has trialPeriodDays of 0', async () => { + describe('integration scenarios', () => { + it('should handle complete subscription lifecycle with updated logic', async () => { await withController( - { - state: { - subscriptions: [], - trialedProducts: [], - pricing: { - products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); + async ({ controller, rootMessenger, mockService }) => { + // 1. Initially no subscription + expect(controller.state.subscriptions).toStrictEqual([]); - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - }, + // 2. Try to cancel subscription (should fail - user not subscribed) + await expect( + rootMessenger.call('SubscriptionController:cancelSubscription', { + subscriptionId: 'sub_123456789', + }), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.UserNotSubscribed, ); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, + // 3. Fetch subscription + mockService.getSubscriptions.mockResolvedValue({ + customerId: 'cus_1', + subscriptions: [MOCK_SUBSCRIPTION], + trialedProducts: [], }); - }, - ); - }); - - it('throws when product pricing is not available', async () => { - await withController( - { - state: { - subscriptions: [], - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, + const subscriptions = await rootMessenger.call( + 'SubscriptionController:getSubscriptions', ); - await expect( - rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', + expect(subscriptions).toStrictEqual([MOCK_SUBSCRIPTION]); + expect(controller.state.subscriptions).toStrictEqual([ + MOCK_SUBSCRIPTION, + ]); + + // 4. Now cancel should work (user is subscribed) + mockService.cancelSubscription.mockResolvedValue(MOCK_SUBSCRIPTION); + expect( + await rootMessenger.call( + 'SubscriptionController:cancelSubscription', { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, + subscriptionId: 'sub_123456789', }, ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.ProductPriceNotFound, - ); + ).toBeUndefined(); - expect(mockService.startSubscriptionWithCard).not.toHaveBeenCalled(); + expect(mockService.cancelSubscription).toHaveBeenCalledWith({ + subscriptionId: 'sub_123456789', + }); }, ); }); + }); + + describe('getPricing', () => { + const mockPricingResponse: PricingResponse = { + products: [], + paymentMethods: [], + }; + + it('should return pricing response', async () => { + await withController(async ({ rootMessenger, mockService }) => { + mockService.getPricing.mockResolvedValue(mockPricingResponse); + + const result = await rootMessenger.call( + 'SubscriptionController:getPricing', + ); + + expect(result).toStrictEqual(mockPricingResponse); + }); + }); + }); - it('does not request a trial when refreshed subscriptions show the product was already trialed', async () => { + describe('getCryptoApproveTransactionParams', () => { + it('returns transaction params for crypto approve transaction', async () => { await withController( { state: { - subscriptions: [], - trialedProducts: [], pricing: MOCK_PRICE_INFO_RESPONSE, }, }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', + async ({ rootMessenger }) => { + const result = rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, }, ); - expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, + expect(result).toStrictEqual({ + approveAmount: '108000000000000000000', + paymentAddress: '0xspender', + paymentTokenAddress: '0xtoken', + chainId: '0x1', }); }, ); }); - it('requests a trial when refreshed subscriptions show the product has not been trialed', async () => { + it('matches the payment token address case-insensitively', async () => { await withController( { state: { - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], pricing: MOCK_PRICE_INFO_RESPONSE, }, }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [], + async ({ controller }) => { + const result = controller.getCryptoApproveTransactionParams({ + chainId: '0x1', + paymentTokenAddress: '0xToKeN', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, }); - mockService.startSubscriptionWithCard.mockResolvedValue( - MOCK_START_SUBSCRIPTION_RESPONSE, - ); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - }, - ); - expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); - expect(mockService.startSubscriptionWithCard).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, + expect(result).toStrictEqual({ + approveAmount: '108000000000000000000', + paymentAddress: '0xspender', + paymentTokenAddress: '0xToKeN', + chainId: '0x1', }); }, ); }); - }); - describe('startCryptoSubscription', () => { - it('should start crypto subscription successfully when user is not subscribed', async () => { + it('throws when pricing not found', async () => { + await withController(async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Subscription pricing not found'); + }); + }); + + it('throws when product price not found', async () => { await withController( { state: { - subscriptions: [], - pricing: MOCK_PRICE_INFO_RESPONSE, + pricing: { + products: [], + paymentMethods: [], + }, }, }, - async ({ rootMessenger, mockService }) => { - const request: StartCryptoSubscriptionRequest = { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }; - - const response: StartCryptoSubscriptionResponse = { - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }; - - mockService.startSubscriptionWithCrypto.mockResolvedValue(response); - mockService.getSubscriptions - .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - const result = await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - request, - ); - - expect(result).toStrictEqual(response); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - request, - ); - expect(mockService.getSubscriptions).toHaveBeenCalledTimes(2); + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Product price not found'); }, ); }); - it('should throw error when products array is empty', async () => { - await withController(async ({ rootMessenger, mockService }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, - ); - - expect(mockService.startSubscriptionWithCrypto).not.toHaveBeenCalled(); - expect(mockService.getSubscriptions).not.toHaveBeenCalled(); - }); - }); - - it('should refresh subscriptions after a successful Money Account crypto start', async () => { - const moneyAccountSubscription: Subscription = { - ...MOCK_SUBSCRIPTION, - id: 'sub_money_account', - products: [ - { - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - }, - ], - }; - + it('throws when price not found for interval', async () => { await withController( { state: { - subscriptions: [], pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], + products: [ + { + name: PRODUCT_TYPES.SHIELD, + prices: [ + { + interval: RECURRING_INTERVALS.year, + currency: 'usd', + unitAmount: 10, + unitDecimals: 18, + trialPeriodDays: 0, + minBillingCycles: 1, + minBillingCyclesForBalance: 1, + }, + ], + }, + ], paymentMethods: [], }, }, }, - async ({ controller, rootMessenger, mockService }) => { - const request: StartCryptoSubscriptionRequest = { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }; - - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [moneyAccountSubscription], - trialedProducts: [], - }); - - const triggerAccessTokenRefreshSpy = jest.spyOn( - controller, - 'triggerAccessTokenRefresh', - ); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - request, - ); - - expect(mockService.getSubscriptions).toHaveBeenCalledTimes(2); - expect( + async ({ rootMessenger }) => { + expect(() => rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, ), - ).toStrictEqual(moneyAccountSubscription); - expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); + ).toThrow('Price not found'); }, ); }); - it('should not refresh subscriptions after a failed crypto start', async () => { + it('throws when chains payment info not found', async () => { await withController( { state: { - subscriptions: [], pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + { + type: PAYMENT_TYPES.byCard, + }, + ], }, }, }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); - mockService.startSubscriptionWithCrypto.mockRejectedValue( - new SubscriptionServiceError('Failed to start crypto subscription'), - ); - - await expect( + async ({ rootMessenger }) => { + expect(() => rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', + 'SubscriptionController:getCryptoApproveTransactionParams', { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, }, ), - ).rejects.toThrow(SubscriptionServiceError); - - expect(mockService.getSubscriptions).toHaveBeenCalledTimes(1); + ).toThrow('Chains payment info not found'); }, ); }); - it('overwrites client-supplied isTrialRequested from pricing and trialedProducts', async () => { + it('throws when invalid chain id', async () => { await withController( { state: { - subscriptions: [], - trialedProducts: [], - pricing: MOCK_PRICE_INFO_RESPONSE, + pricing: { + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + { + type: PAYMENT_TYPES.byCrypto, + chains: [ + { + chainId: '0x2', + paymentAddress: '0xspender', + tokens: [], + }, + ], + }, + ], + }, }, }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ); - - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: true, - }), - ); + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Invalid chain id'); }, ); }); - it('does not request a trial when the product has already been trialed', async () => { + it('throws when invalid token address', async () => { await withController( { state: { - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], pricing: MOCK_PRICE_INFO_RESPONSE, }, }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ); - - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), - ); + async ({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:getCryptoApproveTransactionParams', + { + chainId: '0x1', + paymentTokenAddress: '0xtoken-invalid', + productType: PRODUCT_TYPES.SHIELD, + interval: RECURRING_INTERVALS.month, + }, + ), + ).toThrow('Invalid token address'); }, ); }); - it('does not request a trial when pricing has no trial period', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [], - pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [], - }); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }, - ); - - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), - ); - }, - ); - }); - - it('does not request a trial when Shield pricing has trialPeriodDays of 0', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [], - pricing: { - products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce(MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ); - - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), - ); - }, - ); - }); - - it('does not request a trial when refreshed subscriptions show the product was already trialed', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [], - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ); - - expect(mockService.getSubscriptions).toHaveBeenCalled(); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), - ); - }, - ); - }); - - it('requests a trial when refreshed subscriptions show the product has not been trialed', async () => { - await withController( - { - state: { - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_crypto_123', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'USDC', - rawTransaction: '0xdeadbeef', - }, - ); - - expect(mockService.getSubscriptions).toHaveBeenCalled(); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: true, - }), - ); - }, - ); - }); - - it('should start Money Account crypto subscription while Shield is active', async () => { - await withController( - { - state: { - subscriptions: [MOCK_SUBSCRIPTION], - pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], - }, - }, - }, - async ({ controller, rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }); - mockService.getSubscriptions - .mockResolvedValueOnce({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [ - MOCK_SUBSCRIPTION, - MOCK_MONEY_ACCOUNT_SUBSCRIPTION, - ], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }); - - await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }, - ); - - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - cryptoAuthMethod: 'delegation', - isTrialRequested: false, - }), - ); - expect(controller.state.subscriptions).toHaveLength(2); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.SHIELD, - ), - ).toStrictEqual(MOCK_SUBSCRIPTION); - expect( - rootMessenger.call( - 'SubscriptionController:getSubscriptionByProduct', - PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - ), - ).toStrictEqual(MOCK_MONEY_ACCOUNT_SUBSCRIPTION); - }, - ); - }); - - it('should throw when Money Account is already active', async () => { - await withController( - { - state: { - subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - pricing: { - products: [MOCK_MONEY_ACCOUNT_PRODUCT_PRICE], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_MONEY_ACCOUNT_SUBSCRIPTION], - trialedProducts: [], - }); - - await expect( - rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.UserAlreadySubscribed, - ); - - expect( - mockService.startSubscriptionWithCrypto, - ).not.toHaveBeenCalled(); - }, - ); - }); - - it('throws when product pricing is not available', async () => { - await withController( - { - state: { - subscriptions: [], - }, - }, - async ({ rootMessenger, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_EMPTY_GET_SUBSCRIPTIONS_RESPONSE, - ); - - await expect( - rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCrypto', - { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.ProductPriceNotFound, - ); - - expect( - mockService.startSubscriptionWithCrypto, - ).not.toHaveBeenCalled(); - }, - ); - }); - }); - - describe('startPolling', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - afterEach(() => { - jest.useRealTimers(); - }); - - it('should call getSubscriptions with the correct interval', async () => { - await withController(async ({ controller }) => { - const getSubscriptionsSpy = jest.spyOn(controller, 'getSubscriptions'); - controller.startPolling({}); - await jestAdvanceTime({ duration: 0 }); - expect(getSubscriptionsSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should call `triggerAccessTokenRefresh` when the state changes', async () => { - await withController(async ({ controller, mockService }) => { - mockService.getSubscriptions.mockResolvedValue( - MOCK_GET_SUBSCRIPTIONS_RESPONSE, - ); - const triggerAccessTokenRefreshSpy = jest.spyOn( - controller, - 'triggerAccessTokenRefresh', - ); - controller.startPolling({}); - await jestAdvanceTime({ duration: 0 }); - expect(triggerAccessTokenRefreshSpy).toHaveBeenCalledTimes(1); - }); - }); - }); - - describe('integration scenarios', () => { - it('should handle complete subscription lifecycle with updated logic', async () => { - await withController( - async ({ controller, rootMessenger, mockService }) => { - // 1. Initially no subscription - expect(controller.state.subscriptions).toStrictEqual([]); - - // 2. Try to cancel subscription (should fail - user not subscribed) - await expect( - rootMessenger.call('SubscriptionController:cancelSubscription', { - subscriptionId: 'sub_123456789', - }), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.UserNotSubscribed, - ); - - // 3. Fetch subscription - mockService.getSubscriptions.mockResolvedValue({ - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION], - trialedProducts: [], - }); - const subscriptions = await rootMessenger.call( - 'SubscriptionController:getSubscriptions', - ); - - expect(subscriptions).toStrictEqual([MOCK_SUBSCRIPTION]); - expect(controller.state.subscriptions).toStrictEqual([ - MOCK_SUBSCRIPTION, - ]); - - // 4. Now cancel should work (user is subscribed) - mockService.cancelSubscription.mockResolvedValue(MOCK_SUBSCRIPTION); - expect( - await rootMessenger.call( - 'SubscriptionController:cancelSubscription', - { - subscriptionId: 'sub_123456789', - }, - ), - ).toBeUndefined(); - - expect(mockService.cancelSubscription).toHaveBeenCalledWith({ - subscriptionId: 'sub_123456789', - }); - }, - ); - }); - }); - - describe('getPricing', () => { - const mockPricingResponse: PricingResponse = { - products: [], - paymentMethods: [], - }; - - it('should return pricing response', async () => { - await withController(async ({ rootMessenger, mockService }) => { - mockService.getPricing.mockResolvedValue(mockPricingResponse); - - const result = await rootMessenger.call( - 'SubscriptionController:getPricing', - ); - - expect(result).toStrictEqual(mockPricingResponse); - }); - }); - }); - - describe('getCryptoApproveTransactionParams', () => { - it('selects the erc20 approval payment method for the requested product', async () => { - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'delegation', - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - chains: [ - { - chainId: '0x8f', - paymentAddress: - '0x00000000000000000000000000000000000000c0', - tokens: [], - }, - ], - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - const result = rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ); - - expect(result).toStrictEqual({ - approveAmount: '108000000000000000000', - paymentAddress: '0x00000000000000000000000000000000000000a2', - paymentTokenAddress: '0xtoken', - chainId: '0x1', - }); - }, - ); - }); - - it('does not treat omitted products and cryptoAuthMethod as wildcards for Money Account', async () => { - const shieldPaymentAddress = '0x00000000000000000000000000000000000000a2'; - const mapPaymentAddress = '0x00000000000000000000000000000000000000c0'; - const mapProductPrice: ProductPricing = { - ...MOCK_PRODUCT_PRICE, - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - }; - const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE, mapProductPrice], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - chains: [ - { - chainId: '0x1', - paymentAddress: mapPaymentAddress, - tokens: legacyShieldChain.tokens, - }, - ], - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - const result = rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - interval: RECURRING_INTERVALS.month, - }, - ); - - expect(result.paymentAddress).toBe(mapPaymentAddress); - expect(result.paymentAddress).not.toBe(shieldPaymentAddress); - }, - ); - }); - - it('does not return Shield spender for Money Account when pricing is legacy crypto-only', async () => { - const mapProductPrice: ProductPricing = { - ...MOCK_PRODUCT_PRICE, - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - }; - - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE, mapProductPrice], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Chains payment info not found'); - }, - ); - }); - - it('does not default omitted cryptoAuthMethod to erc20_approval when products is set', async () => { - const mapProductPrice: ProductPricing = { - ...MOCK_PRODUCT_PRICE, - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - }; - const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE, mapProductPrice], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - chains: [ - { - chainId: '0x1', - paymentAddress: - '0x00000000000000000000000000000000000000c0', - tokens: legacyShieldChain.tokens, - }, - ], - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Chains payment info not found'); - }, - ); - }); - - it('does not treat empty products as Shield', async () => { - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - products: [], - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Chains payment info not found'); - }, - ); - }); - - it('prefers an explicit Shield row over a leftover unscoped crypto row', async () => { - const leftoverPaymentAddress = - '0x00000000000000000000000000000000000000aa'; - const shieldPaymentAddress = '0x00000000000000000000000000000000000000a2'; - const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - chains: [ - { - ...legacyShieldChain, - paymentAddress: leftoverPaymentAddress, - }, - ], - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - const result = rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ); - - expect(result.paymentAddress).toBe(shieldPaymentAddress); - expect(result.paymentAddress).not.toBe(leftoverPaymentAddress); - }, - ); - }); - - it('throws when multiple equally specific crypto payment methods match', async () => { - const [legacyShieldChain] = MOCK_PRICING_PAYMENT_METHOD.chains ?? []; - - await withController( - { - state: { - pricing: { - products: [MOCK_PRODUCT_PRICE], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: MOCK_PRICING_PAYMENT_METHOD.chains, - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: [ - { - chainId: '0x1', - paymentAddress: - '0x00000000000000000000000000000000000000bb', - tokens: legacyShieldChain.tokens, - }, - ], - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Multiple matching crypto payment methods found'); - }, - ); - }); - - it('returns transaction params for crypto approve transaction', async () => { - await withController( - { - state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger }) => { - const result = rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ); - - expect(result).toStrictEqual({ - approveAmount: '108000000000000000000', - paymentAddress: '0x00000000000000000000000000000000000000a2', - paymentTokenAddress: '0xtoken', - chainId: '0x1', - }); - }, - ); - }); - - it('matches the payment token address case-insensitively', async () => { - await withController( - { - state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ controller }) => { - const result = controller.getCryptoApproveTransactionParams({ - chainId: '0x1', - paymentTokenAddress: '0xToKeN', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }); - - expect(result).toStrictEqual({ - approveAmount: '108000000000000000000', - paymentAddress: '0x00000000000000000000000000000000000000a2', - paymentTokenAddress: '0xToKeN', - chainId: '0x1', - }); - }, - ); - }); - - it('throws when pricing not found', async () => { - await withController(async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Subscription pricing not found'); - }); - }); - - it('throws when product price not found', async () => { - await withController( - { - state: { - pricing: { - products: [], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Product price not found'); - }, - ); - }); - - it('throws when price not found for interval', async () => { - await withController( - { - state: { - pricing: { - products: [ - { - name: PRODUCT_TYPES.SHIELD, - prices: [ - { - interval: RECURRING_INTERVALS.year, - currency: 'usd', - unitAmount: 10, - unitDecimals: 18, - trialPeriodDays: 0, - minBillingCycles: 1, - minBillingCyclesForBalance: 1, - }, - ], - }, - ], - paymentMethods: [], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Price not found'); - }, - ); - }); - - it('throws when chains payment info not found', async () => { - await withController( - { - state: { - pricing: { - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - { - type: PAYMENT_TYPES.byCard, - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Chains payment info not found'); - }, - ); - }); - - it('throws when invalid chain id', async () => { - await withController( - { - state: { - pricing: { - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - chains: [ - { - chainId: '0x2', - paymentAddress: - '0x00000000000000000000000000000000000000a2', - tokens: [], - }, - ], - }, - ], - }, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Invalid chain id'); - }, - ); - }); - - it('throws when invalid token address', async () => { - await withController( - { - state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:getCryptoApproveTransactionParams', - { - chainId: '0x1', - paymentTokenAddress: '0xtoken-invalid', - productType: PRODUCT_TYPES.SHIELD, - interval: RECURRING_INTERVALS.month, - }, - ), - ).toThrow('Invalid token address'); - }, - ); - }); - - it('throws when conversion rate not found', async () => { + it('throws when conversion rate not found', async () => { await withController( { state: { @@ -2862,8 +1485,7 @@ describe('SubscriptionController', () => { chains: [ { chainId: '0x1', - paymentAddress: - '0x00000000000000000000000000000000000000a2', + paymentAddress: '0xspender', tokens: [ { address: '0xtoken', @@ -3174,1033 +1796,556 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:getSubscriptionsEligibilities', - ), - ).rejects.toThrow(SubscriptionServiceError); - }); - }); - }); - - describe('submitUserEvent', () => { - it('should submit user event successfully', async () => { - await withController(async ({ rootMessenger, mockService }) => { - const submitUserEventSpy = jest - .spyOn(mockService, 'submitUserEvent') - .mockResolvedValue(undefined); - - const result = await rootMessenger.call( - 'SubscriptionController:submitUserEvent', - { - event: SubscriptionUserEvent.ShieldEntryModalViewed, - }, - ); - expect(result).toBeUndefined(); - expect(submitUserEventSpy).toHaveBeenCalledWith({ - event: SubscriptionUserEvent.ShieldEntryModalViewed, - }); - expect(submitUserEventSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should submit user event with cohort successfully', async () => { - await withController(async ({ rootMessenger, mockService }) => { - const submitUserEventSpy = jest - .spyOn(mockService, 'submitUserEvent') - .mockResolvedValue(undefined); - - const result = await rootMessenger.call( - 'SubscriptionController:submitUserEvent', - { - event: SubscriptionUserEvent.ShieldCohortAssigned, - cohort: 'post_tx', - }, - ); - expect(result).toBeUndefined(); - expect(submitUserEventSpy).toHaveBeenCalledWith({ - event: SubscriptionUserEvent.ShieldCohortAssigned, - cohort: 'post_tx', - }); - expect(submitUserEventSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should handle subscription service errors', async () => { - await withController(async ({ rootMessenger, mockService }) => { - const errorMessage = 'Failed to submit user event'; - mockService.submitUserEvent.mockRejectedValue( - new SubscriptionServiceError(errorMessage), - ); - - await expect( - rootMessenger.call('SubscriptionController:submitUserEvent', { - event: SubscriptionUserEvent.ShieldEntryModalViewed, - }), - ).rejects.toThrow(SubscriptionServiceError); - }); - }); - }); - - describe('assignUserToCohort', () => { - it('should assign user to cohort successfully', async () => { - await withController(async ({ rootMessenger, mockService }) => { - const assignUserToCohortSpy = jest - .spyOn(mockService, 'assignUserToCohort') - .mockResolvedValue(undefined); - - const result = await rootMessenger.call( - 'SubscriptionController:assignUserToCohort', - { - cohort: 'post_tx', - }, - ); - expect(result).toBeUndefined(); - expect(assignUserToCohortSpy).toHaveBeenCalledWith({ - cohort: 'post_tx', - }); - expect(assignUserToCohortSpy).toHaveBeenCalledTimes(1); - }); - }); - - it('should handle subscription service errors', async () => { - await withController(async ({ rootMessenger, mockService }) => { - const errorMessage = 'Failed to assign user to cohort'; - mockService.assignUserToCohort.mockRejectedValue( - new SubscriptionServiceError(errorMessage), - ); - - await expect( - rootMessenger.call('SubscriptionController:assignUserToCohort', { - cohort: 'post_tx', - }), - ).rejects.toThrow(SubscriptionServiceError); - }); - }); - }); - - describe('cacheLastSelectedPaymentMethod', () => { - const MOCK_CACHED_PAYMENT_METHOD: CachedLastSelectedPaymentMethod = { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0x123', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }; - - it('should cache last selected payment method successfully', async () => { - await withController(async ({ controller, rootMessenger }) => { - rootMessenger.call( - 'SubscriptionController:cacheLastSelectedPaymentMethod', - { - product: PRODUCT_TYPES.SHIELD, - paymentMethod: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }, - ); - - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }); - }); - }); - - it('should cache Money Account payment method without clobbering Shield', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - }, - }, - }, - async ({ controller, rootMessenger }) => { - rootMessenger.call( - 'SubscriptionController:cacheLastSelectedPaymentMethod', - { - product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - paymentMethod: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xmoneytoken', - paymentTokenSymbol: 'pvmUSD', - plan: RECURRING_INTERVALS.month, - cryptoAuthMethod: 'delegation', - }, - }, - ); - - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xmoneytoken', - paymentTokenSymbol: 'pvmUSD', - plan: RECURRING_INTERVALS.month, - cryptoAuthMethod: 'delegation', - }, - }); - }, - ); - }); - - it('should update the last selected payment method for the same product', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }, - }, - }, - async ({ controller, rootMessenger }) => { - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }); - - rootMessenger.call( - 'SubscriptionController:cacheLastSelectedPaymentMethod', - { - product: PRODUCT_TYPES.SHIELD, - paymentMethod: MOCK_CACHED_PAYMENT_METHOD, - }, - ); - - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: MOCK_CACHED_PAYMENT_METHOD, - }); - }, - ); - }); - - it('should throw error when payment token address is not provided for crypto payment', async () => { - await withController(({ rootMessenger }) => { - expect(() => - rootMessenger.call( - 'SubscriptionController:cacheLastSelectedPaymentMethod', - { - product: PRODUCT_TYPES.SHIELD, - paymentMethod: { - type: PAYMENT_TYPES.byCrypto, - plan: RECURRING_INTERVALS.month, - } as CachedLastSelectedPaymentMethod, - }, + 'SubscriptionController:getSubscriptionsEligibilities', ), - ).toThrow( - SubscriptionControllerErrorMessage.PaymentTokenAddressAndSymbolRequiredForCrypto, - ); + ).rejects.toThrow(SubscriptionServiceError); }); }); }); - describe('clearLastSelectedPaymentMethod', () => { - it('should clear last selected payment method successfully', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }, - }, - }, - async ({ controller, rootMessenger }) => { - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, - plan: RECURRING_INTERVALS.month, - }, - }); - - rootMessenger.call( - 'SubscriptionController:clearLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, - ); + describe('submitUserEvent', () => { + it('should submit user event successfully', async () => { + await withController(async ({ rootMessenger, mockService }) => { + const submitUserEventSpy = jest + .spyOn(mockService, 'submitUserEvent') + .mockResolvedValue(undefined); - expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({}); - }, - ); + const result = await rootMessenger.call( + 'SubscriptionController:submitUserEvent', + { + event: SubscriptionUserEvent.ShieldEntryModalViewed, + }, + ); + expect(result).toBeUndefined(); + expect(submitUserEventSpy).toHaveBeenCalledWith({ + event: SubscriptionUserEvent.ShieldEntryModalViewed, + }); + expect(submitUserEventSpy).toHaveBeenCalledTimes(1); + }); }); - it('should do nothing when lastSelectedPaymentMethod is undefined', async () => { - await withController(async ({ controller, rootMessenger }) => { - expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); + it('should submit user event with cohort successfully', async () => { + await withController(async ({ rootMessenger, mockService }) => { + const submitUserEventSpy = jest + .spyOn(mockService, 'submitUserEvent') + .mockResolvedValue(undefined); - rootMessenger.call( - 'SubscriptionController:clearLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, + const result = await rootMessenger.call( + 'SubscriptionController:submitUserEvent', + { + event: SubscriptionUserEvent.ShieldCohortAssigned, + cohort: 'post_tx', + }, ); - - expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); + expect(result).toBeUndefined(); + expect(submitUserEventSpy).toHaveBeenCalledWith({ + event: SubscriptionUserEvent.ShieldCohortAssigned, + cohort: 'post_tx', + }); + expect(submitUserEventSpy).toHaveBeenCalledTimes(1); }); }); - it('should remove the product key while preserving the state object', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0x123', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - 'test-product-type': { - type: PAYMENT_TYPES.byCard, - }, - } as Record, - }, - }, - async ({ controller, rootMessenger }) => { - expect( - controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], - ).toBeDefined(); - - rootMessenger.call( - 'SubscriptionController:clearLastSelectedPaymentMethod', - PRODUCT_TYPES.SHIELD, - ); + it('should handle subscription service errors', async () => { + await withController(async ({ rootMessenger, mockService }) => { + const errorMessage = 'Failed to submit user event'; + mockService.submitUserEvent.mockRejectedValue( + new SubscriptionServiceError(errorMessage), + ); - expect( - controller.state.lastSelectedPaymentMethod?.[ - 'test-product-type' as ProductType - ], - ).toBeDefined(); - expect( - controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], - ).toBeUndefined(); - }, - ); + await expect( + rootMessenger.call('SubscriptionController:submitUserEvent', { + event: SubscriptionUserEvent.ShieldEntryModalViewed, + }), + ).rejects.toThrow(SubscriptionServiceError); + }); }); }); - describe('clearState', () => { - it('should reset state to default values', async () => { - await withController( - { - state: { - subscriptions: [MOCK_SUBSCRIPTION], - pricing: MOCK_PRICE_INFO_RESPONSE, - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - }, + describe('assignUserToCohort', () => { + it('should assign user to cohort successfully', async () => { + await withController(async ({ rootMessenger, mockService }) => { + const assignUserToCohortSpy = jest + .spyOn(mockService, 'assignUserToCohort') + .mockResolvedValue(undefined); + + const result = await rootMessenger.call( + 'SubscriptionController:assignUserToCohort', + { + cohort: 'post_tx', }, - }, - async ({ controller, rootMessenger }) => { - expect(controller.state.subscriptions).toStrictEqual([ - MOCK_SUBSCRIPTION, - ]); - expect(controller.state.pricing).toStrictEqual( - MOCK_PRICE_INFO_RESPONSE, - ); + ); + expect(result).toBeUndefined(); + expect(assignUserToCohortSpy).toHaveBeenCalledWith({ + cohort: 'post_tx', + }); + expect(assignUserToCohortSpy).toHaveBeenCalledTimes(1); + }); + }); - rootMessenger.call('SubscriptionController:clearState'); + it('should handle subscription service errors', async () => { + await withController(async ({ rootMessenger, mockService }) => { + const errorMessage = 'Failed to assign user to cohort'; + mockService.assignUserToCohort.mockRejectedValue( + new SubscriptionServiceError(errorMessage), + ); - expect(controller.state).toStrictEqual( - getDefaultSubscriptionControllerState(), - ); - expect(controller.state.subscriptions).toHaveLength(0); - expect(controller.state.pricing).toBeUndefined(); - expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); - }, - ); + await expect( + rootMessenger.call('SubscriptionController:assignUserToCohort', { + cohort: 'post_tx', + }), + ).rejects.toThrow(SubscriptionServiceError); + }); }); }); - describe('submitSponsorshipIntents', () => { - const MOCK_SUBMISSION_INTENTS_REQUEST: SubmitSponsorshipIntentsMethodParams = - { - chainId: '0x1', - address: '0x1234567890123456789012345678901234567890', - products: [PRODUCT_TYPES.SHIELD], - }; - const MOCK_CACHED_PAYMENT_METHOD: Record< - ProductType, - CachedLastSelectedPaymentMethod - > = { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, + describe('cacheLastSelectedPaymentMethod', () => { + const MOCK_CACHED_PAYMENT_METHOD: CachedLastSelectedPaymentMethod = { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0x123', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, }; - it('should submit sponsorship intents successfully', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: MOCK_PRICE_INFO_RESPONSE, + it('should cache last selected payment method successfully', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.call( + 'SubscriptionController:cacheLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, + { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, }, - }, - async ({ rootMessenger, mockService }) => { - const submitSponsorshipIntentsSpy = jest - .spyOn(mockService, 'submitSponsorshipIntents') - .mockResolvedValue(undefined); - - await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ); - expect(submitSponsorshipIntentsSpy).toHaveBeenCalledWith({ - ...MOCK_SUBMISSION_INTENTS_REQUEST, - paymentTokenSymbol: 'USDT', - billingCycles: 12, - recurringInterval: RECURRING_INTERVALS.month, - }); - }, - ); - }); - - it('should throw error when products array is empty', async () => { - await withController(async ({ rootMessenger }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - { - ...MOCK_SUBMISSION_INTENTS_REQUEST, - products: [], - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, ); - }); - }); - it('should throw error when user is already subscribed', async () => { - await withController( - { - state: { - subscriptions: [MOCK_SUBSCRIPTION], + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, }, - }, - async ({ rootMessenger, mockService }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.UserAlreadySubscribed, - ); - - // Verify the subscription service was not called - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); - }, - ); + }); + }); }); - it('should not submit sponsorship intents if the user has trailed the products before', async () => { + it('should update the last selected payment method for the same product', async () => { await withController( { state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - subscriptions: [ - { - ...MOCK_SUBSCRIPTION, - status: SUBSCRIPTION_STATUSES.canceled, + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, }, - ], - pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [PRODUCT_TYPES.SHIELD], + }, }, }, - async ({ rootMessenger, mockService }) => { - mockService.submitSponsorshipIntents.mockResolvedValue(undefined); + async ({ controller, rootMessenger }) => { + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, + }, + }); - const isSponsored = await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, + rootMessenger.call( + 'SubscriptionController:cacheLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, + MOCK_CACHED_PAYMENT_METHOD, ); - expect(isSponsored).toBe(false); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); - }, - ); - }); - it('should not submit sponsorship intents if the chain does not support sponsorship', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: { - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - ...MOCK_PRICE_INFO_RESPONSE.paymentMethods.map( - (paymentMethod) => - paymentMethod.type === PAYMENT_TYPES.byCrypto - ? { - ...paymentMethod, - chains: paymentMethod.chains?.map((chain) => ({ - ...chain, - isSponsorshipSupported: false, // <==== Sponsorship not supported - })), - } - : paymentMethod, - ), - ], - }, - }, - }, - async ({ rootMessenger, mockService }) => { - const isSponsored = await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ); - expect(isSponsored).toBe(false); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: MOCK_CACHED_PAYMENT_METHOD, + }); }, ); }); - it('looks up sponsorship on the selected crypto auth method, not always erc20_approval', async () => { - const moneyAccountPrice: ProductPricing = { - ...MOCK_PRODUCT_PRICE, - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - }; - const moneyAccountRequest: SubmitSponsorshipIntentsMethodParams = { - ...MOCK_SUBMISSION_INTENTS_REQUEST, - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - }; + it('should throw error when payment token address is not provided for crypto payment', async () => { + await withController(({ rootMessenger }) => { + expect(() => + rootMessenger.call( + 'SubscriptionController:cacheLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, + { + type: PAYMENT_TYPES.byCrypto, + plan: RECURRING_INTERVALS.month, + } as CachedLastSelectedPaymentMethod, + ), + ).toThrow( + SubscriptionControllerErrorMessage.PaymentTokenAddressAndSymbolRequiredForCrypto, + ); + }); + }); + }); + describe('clearLastSelectedPaymentMethod', () => { + it('should clear last selected payment method successfully', async () => { await withController( { state: { lastSelectedPaymentMethod: { - [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDC', + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, plan: RECURRING_INTERVALS.month, - cryptoAuthMethod: 'delegation', }, }, - pricing: { - products: [moneyAccountPrice], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: [ - { - chainId: '0x1', - paymentAddress: - '0x00000000000000000000000000000000000000a2', - isSponsorshipSupported: false, - tokens: [], - }, - ], - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'delegation', - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - chains: [ - { - chainId: '0x1', - paymentAddress: - '0x00000000000000000000000000000000000000c0', - isSponsorshipSupported: true, - tokens: [], - }, - ], - }, - ], - }, }, }, - async ({ rootMessenger, mockService }) => { - mockService.submitSponsorshipIntents.mockResolvedValue(undefined); + async ({ controller, rootMessenger }) => { + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({ + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, + plan: RECURRING_INTERVALS.month, + }, + }); - const isSponsored = await rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - moneyAccountRequest, + rootMessenger.call( + 'SubscriptionController:clearLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, ); - expect(isSponsored).toBe(true); - expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ - ...moneyAccountRequest, - paymentTokenSymbol: 'USDC', - billingCycles: 12, - recurringInterval: RECURRING_INTERVALS.month, - }); + expect(controller.state.lastSelectedPaymentMethod).toStrictEqual({}); }, ); }); - it('throws when the pricing chain row is missing instead of treating it as not sponsored', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: MOCK_PRICE_INFO_RESPONSE, - }, - }, - async ({ rootMessenger, mockService }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - { - ...MOCK_SUBMISSION_INTENTS_REQUEST, - chainId: '0x89', - }, - ), - ).rejects.toThrow('Invalid chain id'); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); - }, - ); - }); + it('should do nothing when lastSelectedPaymentMethod is undefined', async () => { + await withController(async ({ controller, rootMessenger }) => { + expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); - it('throws when pricing is missing instead of treating it as not sponsored', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - }, - }, - async ({ rootMessenger, mockService }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow('Chains payment info not found'); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); - }, - ); + rootMessenger.call( + 'SubscriptionController:clearLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, + ); + + expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); + }); }); - it('throws when the crypto payment method row is missing instead of treating it as not sponsored', async () => { + it('should remove the product key while preserving the state object', async () => { await withController( { state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: { - ...MOCK_PRICE_INFO_RESPONSE, - paymentMethods: [ - { - type: PAYMENT_TYPES.byCard, - products: [PRODUCT_TYPES.SHIELD], - }, - ], - }, + lastSelectedPaymentMethod: { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0x123', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + 'test-product-type': { + type: PAYMENT_TYPES.byCard, + }, + } as Record, }, }, - async ({ rootMessenger, mockService }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow('Chains payment info not found'); - expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); - }, - ); - }); + async ({ controller, rootMessenger }) => { + expect( + controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], + ).toBeDefined(); - it('should throw error when no cached payment method is found', async () => { - await withController(async ({ rootMessenger }) => { - await expect( rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, - ); - }); + 'SubscriptionController:clearLastSelectedPaymentMethod', + PRODUCT_TYPES.SHIELD, + ); + + expect( + controller.state.lastSelectedPaymentMethod?.[ + 'test-product-type' as ProductType + ], + ).toBeDefined(); + expect( + controller.state.lastSelectedPaymentMethod?.[PRODUCT_TYPES.SHIELD], + ).toBeUndefined(); + }, + ); }); + }); - it('should throw error when payment method is not crypto', async () => { + describe('clearState', () => { + it('should reset state to default values', async () => { await withController( { state: { + subscriptions: [MOCK_SUBSCRIPTION], + pricing: MOCK_PRICE_INFO_RESPONSE, lastSelectedPaymentMethod: { [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCard, + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', plan: RECURRING_INTERVALS.month, }, }, }, }, - async ({ rootMessenger }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, + async ({ controller, rootMessenger }) => { + expect(controller.state.subscriptions).toStrictEqual([ + MOCK_SUBSCRIPTION, + ]); + expect(controller.state.pricing).toStrictEqual( + MOCK_PRICE_INFO_RESPONSE, ); - }, - ); - }); - it('should throw error when product price is not found', async () => { - await withController( - { - state: { - lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, - pricing: { - products: [], - paymentMethods: [MOCK_PRICING_PAYMENT_METHOD], - }, - }, - }, - async ({ rootMessenger }) => { - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.ProductPriceNotFound, + rootMessenger.call('SubscriptionController:clearState'); + + expect(controller.state).toStrictEqual( + getDefaultSubscriptionControllerState(), ); + expect(controller.state.subscriptions).toHaveLength(0); + expect(controller.state.pricing).toBeUndefined(); + expect(controller.state.lastSelectedPaymentMethod).toBeUndefined(); }, ); }); + }); - it('should handle subscription service errors', async () => { + describe('submitSponsorshipIntents', () => { + const MOCK_SUBMISSION_INTENTS_REQUEST: SubmitSponsorshipIntentsMethodParams = + { + chainId: '0x1', + address: '0x1234567890123456789012345678901234567890', + products: [PRODUCT_TYPES.SHIELD], + }; + const MOCK_CACHED_PAYMENT_METHOD: Record< + ProductType, + CachedLastSelectedPaymentMethod + > = { + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCrypto, + paymentTokenAddress: '0xtoken', + paymentTokenSymbol: 'USDT', + plan: RECURRING_INTERVALS.month, + }, + }; + + it('should submit sponsorship intents successfully', async () => { await withController( { state: { - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - ...MOCK_CACHED_PAYMENT_METHOD[PRODUCT_TYPES.SHIELD], - plan: RECURRING_INTERVALS.year, - }, - }, + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.submitSponsorshipIntents.mockRejectedValue( - new SubscriptionServiceError( - 'Failed to submit sponsorship intents', - ), - ); + const submitSponsorshipIntentsSpy = jest + .spyOn(mockService, 'submitSponsorshipIntents') + .mockResolvedValue(undefined); - await expect( - rootMessenger.call( - 'SubscriptionController:submitSponsorshipIntents', - MOCK_SUBMISSION_INTENTS_REQUEST, - ), - ).rejects.toThrow(SubscriptionServiceError); - expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ + await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ); + expect(submitSponsorshipIntentsSpy).toHaveBeenCalledWith({ ...MOCK_SUBMISSION_INTENTS_REQUEST, paymentTokenSymbol: 'USDT', - billingCycles: 1, - recurringInterval: RECURRING_INTERVALS.year, + billingCycles: 12, + recurringInterval: RECURRING_INTERVALS.month, }); }, ); }); - }); - - describe('submitSubscriptionCryptoApproval', () => { - it('accepts only Shield as productType at compile time', () => { - type ProductArg = Parameters< - SubscriptionController['submitSubscriptionCryptoApproval'] - >[0]['productType']; - const shield: ProductArg = PRODUCT_TYPES.SHIELD; - expect(shield).toBe(PRODUCT_TYPES.SHIELD); - - // @ts-expect-error only Shield is a valid productType - const moneyAccount: ProductArg = PRODUCT_TYPES.MONEY_ACCOUNT_PLUS; - expect(moneyAccount).toBe(PRODUCT_TYPES.MONEY_ACCOUNT_PLUS); + it('should throw error when products array is empty', async () => { + await withController(async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + { + ...MOCK_SUBMISSION_INTENTS_REQUEST, + products: [], + }, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, + ); + }); }); - it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => { + it('should throw error when user is already subscribed', async () => { await withController( { state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [], - subscriptions: [], - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - }, + subscriptions: [MOCK_SUBSCRIPTION], }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_123', - status: SUBSCRIPTION_STATUSES.trialing, - }); - - mockService.getSubscriptions - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - // Create a shield subscription approval transaction - const txMeta = { - ...generateMockTxMeta(), - type: TransactionType.shieldSubscriptionApprove, - chainId: '0x1' as Hex, - rawTx: '0x123', - txParams: { - data: '0x456', - from: '0x1234567890123456789012345678901234567890', - to: '0xtoken', - }, - status: TransactionStatus.submitted, - }; - - await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.UserAlreadySubscribed, ); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes( - 1, - ); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: true, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x1', - payerAddress: '0x1234567890123456789012345678901234567890', - tokenSymbol: 'USDT', - rawTransaction: '0x123', - cryptoAuthMethod: 'erc20_approval', - isSponsored: undefined, - useTestClock: undefined, - rewardAccountId: undefined, - }); + // Verify the subscription service was not called + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); }, ); }); - it('should not request trial when Shield pricing has trialPeriodDays of 0', async () => { + it('should not submit sponsorship intents if the user has trailed the products before', async () => { await withController( { state: { - pricing: { - products: [MOCK_PRODUCT_PRICE_WITHOUT_TRIAL], - paymentMethods: MOCK_PRICE_INFO_RESPONSE.paymentMethods, - }, - trialedProducts: [], - subscriptions: [], - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + subscriptions: [ + { + ...MOCK_SUBSCRIPTION, + status: SUBSCRIPTION_STATUSES.canceled, }, - }, + ], + pricing: MOCK_PRICE_INFO_RESPONSE, + trialedProducts: [PRODUCT_TYPES.SHIELD], }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_123', - status: SUBSCRIPTION_STATUSES.active, - }); - - mockService.getSubscriptions - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - const txMeta = { - ...generateMockTxMeta(), - type: TransactionType.shieldSubscriptionApprove, - chainId: '0x1' as Hex, - rawTx: '0x123', - txParams: { - data: '0x456', - from: '0x1234567890123456789012345678901234567890', - to: '0xtoken', - }, - status: TransactionStatus.submitted, - }; + mockService.submitSponsorshipIntents.mockResolvedValue(undefined); - await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + const isSponsored = await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, ); + expect(isSponsored).toBe(false); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); + }, + ); + }); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - cryptoAuthMethod: 'erc20_approval', - }), + it('should not submit sponsorship intents if the chain does not support sponsorship', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + pricing: { + ...MOCK_PRICE_INFO_RESPONSE, + paymentMethods: [ + ...MOCK_PRICE_INFO_RESPONSE.paymentMethods.map( + (paymentMethod) => ({ + ...paymentMethod, + chains: paymentMethod.chains?.map((chain) => ({ + ...chain, + isSponsorshipSupported: false, // <==== Sponsorship not supported + })), + }), + ), + ], + }, + }, + }, + async ({ rootMessenger, mockService }) => { + const isSponsored = await rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, ); + expect(isSponsored).toBe(false); + expect(mockService.submitSponsorshipIntents).not.toHaveBeenCalled(); }, ); }); - it('should throw when Shield crypto approval is submitted with only Money Account payment method cached', async () => { + it('should throw error when no cached payment method is found', async () => { + await withController(async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, + ); + }); + }); + + it('should throw error when payment method is not crypto', async () => { await withController( { state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [], - subscriptions: [], lastSelectedPaymentMethod: { - [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xmoneytoken', - paymentTokenSymbol: 'pvmUSD', + [PRODUCT_TYPES.SHIELD]: { + type: PAYMENT_TYPES.byCard, plan: RECURRING_INTERVALS.month, - cryptoAuthMethod: 'delegation', }, }, }, }, - async ({ rootMessenger, mockService }) => { - const txMeta = { - ...generateMockTxMeta(), - type: TransactionType.shieldSubscriptionApprove, - chainId: '0x1' as Hex, - rawTx: '0x123', - txParams: { - data: '0x456', - from: '0x1234567890123456789012345678901234567890', - to: '0xtoken', - }, - status: TransactionStatus.submitted, - }; - + async ({ rootMessenger }) => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.PaymentMethodNotCrypto, ); + }, + ); + }); - expect( - mockService.startSubscriptionWithCrypto, - ).not.toHaveBeenCalled(); + it('should throw error when product price is not found', async () => { + await withController( + { + state: { + lastSelectedPaymentMethod: MOCK_CACHED_PAYMENT_METHOD, + pricing: { + products: [], + paymentMethods: [MOCK_PRICING_PAYMENT_METHOD], + }, + }, + }, + async ({ rootMessenger }) => { + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow( + SubscriptionControllerErrorMessage.ProductPriceNotFound, + ); }, ); }); - it('should not request trial when product was already trialed', async () => { + it('should handle subscription service errors', async () => { await withController( { state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [PRODUCT_TYPES.SHIELD], - subscriptions: [], lastSelectedPaymentMethod: { [PRODUCT_TYPES.SHIELD]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, + ...MOCK_CACHED_PAYMENT_METHOD[PRODUCT_TYPES.SHIELD], + plan: RECURRING_INTERVALS.year, }, }, + pricing: MOCK_PRICE_INFO_RESPONSE, }, }, async ({ rootMessenger, mockService }) => { - mockService.startSubscriptionWithCrypto.mockResolvedValue({ - subscriptionId: 'sub_123', - status: SUBSCRIPTION_STATUSES.active, - }); - - mockService.getSubscriptions - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); - - const txMeta = { - ...generateMockTxMeta(), - type: TransactionType.shieldSubscriptionApprove, - chainId: '0x1' as Hex, - rawTx: '0x123', - txParams: { - data: '0x456', - from: '0x1234567890123456789012345678901234567890', - to: '0xtoken', - }, - status: TransactionStatus.submitted, - }; - - await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + mockService.submitSponsorshipIntents.mockRejectedValue( + new SubscriptionServiceError( + 'Failed to submit sponsorship intents', + ), ); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), - ); + await expect( + rootMessenger.call( + 'SubscriptionController:submitSponsorshipIntents', + MOCK_SUBMISSION_INTENTS_REQUEST, + ), + ).rejects.toThrow(SubscriptionServiceError); + expect(mockService.submitSponsorshipIntents).toHaveBeenCalledWith({ + ...MOCK_SUBMISSION_INTENTS_REQUEST, + paymentTokenSymbol: 'USDT', + billingCycles: 1, + recurringInterval: RECURRING_INTERVALS.year, + }); }, ); }); + }); - it('should not request trial when refreshed subscriptions show the product was already trialed', async () => { + describe('submitShieldSubscriptionCryptoApproval', () => { + it('should handle subscription crypto approval when shield subscription transaction is submitted', async () => { await withController( { state: { @@ -4220,20 +2365,17 @@ describe('SubscriptionController', () => { async ({ rootMessenger, mockService }) => { mockService.startSubscriptionWithCrypto.mockResolvedValue({ subscriptionId: 'sub_123', - status: SUBSCRIPTION_STATUSES.active, + status: SUBSCRIPTION_STATUSES.trialing, }); mockService.getSubscriptions .mockResolvedValueOnce({ subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }) - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [PRODUCT_TYPES.SHIELD], + trialedProducts: [], }) .mockResolvedValue(MOCK_GET_SUBSCRIPTIONS_RESPONSE); + // Create a shield subscription approval transaction const txMeta = { ...generateMockTxMeta(), type: TransactionType.shieldSubscriptionApprove, @@ -4248,17 +2390,12 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ); - expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith( - expect.objectContaining({ - isTrialRequested: false, - }), + expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledTimes( + 1, ); }, ); @@ -4288,10 +2425,6 @@ describe('SubscriptionController', () => { }); mockService.getSubscriptions - .mockResolvedValueOnce({ - subscriptions: [], - trialedProducts: [], - }) .mockResolvedValueOnce({ subscriptions: [], trialedProducts: [], @@ -4313,14 +2446,10 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - isSponsored: false, - rewardAccountId: - 'eip155:1:0x1234567890123456789012345678901234567890', - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, + false, // isSponsored + 'eip155:1:0x1234567890123456789012345678901234567890', ); expect(mockService.startSubscriptionWithCrypto).toHaveBeenCalledWith({ @@ -4332,7 +2461,6 @@ describe('SubscriptionController', () => { payerAddress: '0x1234567890123456789012345678901234567890', tokenSymbol: 'USDT', rawTransaction: '0x123', - cryptoAuthMethod: 'erc20_approval', isSponsored: false, useTestClock: undefined, rewardAccountId: @@ -4363,11 +2491,8 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ), ).rejects.toThrow('Subscription pricing not found'); @@ -4379,7 +2504,7 @@ describe('SubscriptionController', () => { ); }); - it('should throw for non-shield-approve transaction types', async () => { + it('should not handle subscription crypto approval for non-shield subscription transactions', async () => { await withController( { state: { @@ -4389,6 +2514,7 @@ describe('SubscriptionController', () => { }, }, async ({ rootMessenger, mockService }) => { + // Create a non-shield subscription transaction const txMeta = { ...generateMockTxMeta(), type: TransactionType.contractInteraction, @@ -4396,69 +2522,12 @@ describe('SubscriptionController', () => { hash: '0x123', }; - await expect( - rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, - ); - - expect( - mockService.startSubscriptionWithCrypto, - ).not.toHaveBeenCalled(); - }, - ); - }); - - it('should throw when productType is not Shield', async () => { - await withController( - { - state: { - pricing: MOCK_PRICE_INFO_RESPONSE, - trialedProducts: [], - subscriptions: [], - lastSelectedPaymentMethod: { - [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS]: { - type: PAYMENT_TYPES.byCrypto, - paymentTokenAddress: '0xtoken', - paymentTokenSymbol: 'USDT', - plan: RECURRING_INTERVALS.month, - }, - }, - }, - }, - async ({ rootMessenger, mockService }) => { - const txMeta = { - ...generateMockTxMeta(), - type: TransactionType.shieldSubscriptionApprove, - chainId: '0x1' as Hex, - rawTx: '0x123', - txParams: { - data: '0x456', - from: '0x1234567890123456789012345678901234567890', - to: '0xtoken', - }, - status: TransactionStatus.submitted, - }; - - await expect( - rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - // @ts-expect-error only Shield is a valid productType - productType: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - txMeta, - }, - ), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, + await rootMessenger.call( + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ); + // Verify that decodeTransactionDataHandler was not called expect( mockService.startSubscriptionWithCrypto, ).not.toHaveBeenCalled(); @@ -4493,11 +2562,8 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ), ).rejects.toThrow('Chain ID or raw transaction not found'); @@ -4536,11 +2602,8 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ), ).rejects.toThrow('Last selected payment method not found'); @@ -4586,11 +2649,8 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.ProductPriceNotFound, @@ -4640,11 +2700,8 @@ describe('SubscriptionController', () => { }; await rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ); expect(mockService.updatePaymentMethodCrypto).toHaveBeenCalledTimes( @@ -4700,11 +2757,8 @@ describe('SubscriptionController', () => { await expect( rootMessenger.call( - 'SubscriptionController:submitSubscriptionCryptoApproval', - { - productType: PRODUCT_TYPES.SHIELD, - txMeta, - }, + 'SubscriptionController:submitShieldSubscriptionCryptoApproval', + txMeta, ), ).rejects.toThrow( SubscriptionControllerErrorMessage.SubscriptionNotValidForCryptoApproval, diff --git a/packages/subscription-controller/src/SubscriptionController.ts b/packages/subscription-controller/src/SubscriptionController.ts index a1692a371e1..8764e77bd42 100644 --- a/packages/subscription-controller/src/SubscriptionController.ts +++ b/packages/subscription-controller/src/SubscriptionController.ts @@ -6,6 +6,7 @@ import type { import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import { TransactionType } from '@metamask/transaction-controller'; import type { CaipAccountId, Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -34,7 +35,6 @@ import type { SubscriptionServiceUpdatePaymentMethodCryptoAction, } from './SubscriptionService-method-action-types.js'; import { - CRYPTO_AUTH_METHODS, PAYMENT_TYPES, PRODUCT_TYPES, SUBSCRIPTION_STATUSES, @@ -42,20 +42,17 @@ import { import type { AssignCohortRequest, BillingPortalResponse, - CryptoAuthMethod, GetCryptoApproveTransactionRequest, GetCryptoApproveTransactionResponse, GetSubscriptionsEligibilitiesRequest, ProductPrice, SubscriptionEligibility, StartCryptoSubscriptionRequest, - SubmitSubscriptionCryptoApprovalRequest, SubmitUserEventRequest, TokenPaymentInfo, UpdatePaymentMethodCardResponse, UpdatePaymentMethodOpts, CachedLastSelectedPaymentMethod, - CacheLastSelectedPaymentMethodRequest, SubmitSponsorshipIntentsMethodParams, RecurringInterval, SubscriptionStatus, @@ -63,7 +60,6 @@ import type { StartCryptoSubscriptionResponse, StartSubscriptionResponse, CancelSubscriptionRequest, - PricingCryptoPaymentMethod, } from './types.js'; import type { PricingResponse, @@ -86,8 +82,9 @@ export type SubscriptionControllerState = { * This is used to display the last selected payment method in the UI. * This state is also meant to be used internally to track the last selected payment method for the user. (e.g. for crypto subscriptions) */ - lastSelectedPaymentMethod?: Partial< - Record + lastSelectedPaymentMethod?: Record< + ProductType, + CachedLastSelectedPaymentMethod >; }; @@ -224,10 +221,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'getSubscriptionsEligibilities', 'cancelSubscription', 'unCancelSubscription', - 'startSubscriptionWithCard', + 'startShieldSubscriptionWithCard', 'startSubscriptionWithCrypto', 'stopAllPolling', - 'submitSubscriptionCryptoApproval', + 'submitShieldSubscriptionCryptoApproval', 'getCryptoApproveTransactionParams', 'updatePaymentMethod', 'getBillingPortalUrl', @@ -418,114 +415,47 @@ export class SubscriptionController extends StaticIntervalPollingController()< this.triggerAccessTokenRefresh(); } - /** - * Starts a card-paid subscription checkout session for the requested products - * (e.g. Shield or Money Account Plus). - * - * `isTrialRequested` on the request is ignored and overwritten from pricing - * (`trialPeriodDays > 0`) and `trialedProducts`. - * - * @param request - The start subscription request. - * @returns The checkout session response. - */ - async startSubscriptionWithCard( + async startShieldSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise { - // get the latest subscriptions state before computing trial eligibility - await this.getSubscriptions(); this.#assertIsUserNotSubscribed({ products: request.products }); const response = await this.messenger.call( 'SubscriptionService:startSubscriptionWithCard', - { - ...request, - isTrialRequested: this.#getIsTrialRequested( - request.products, - request.recurringInterval, - ), - }, + request, ); // note: no need to trigger access token refresh after startSubscriptionWithCard request because this only return stripe checkout session url, subscription not created yet return response; } - /** - * Starts a crypto-paid subscription for the requested products - * (e.g. Shield or Money Account Plus). Unlike card checkout, this - * creates the subscription immediately, so local state is refreshed - * afterwards. - * - * `isTrialRequested` on the request is ignored and overwritten from pricing - * (`trialPeriodDays > 0`) and `trialedProducts`. - * - * @param request - The start crypto subscription request. - * @returns The start crypto subscription response. - * @throws If `products` is empty. - */ async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { - if (request.products.length === 0) { - throw new Error( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, - ); - } - - // get the latest subscriptions state before computing trial eligibility - await this.getSubscriptions(); this.#assertIsUserNotSubscribed({ products: request.products }); const response = await this.messenger.call( 'SubscriptionService:startSubscriptionWithCrypto', - { - ...request, - isTrialRequested: this.#getIsTrialRequested( - request.products, - request.recurringInterval, - ), - }, + request, ); - // Crypto start creates the subscription immediately (unlike card checkout). - await this.getSubscriptions(); - return response; } /** - * Submits a Shield ERC-20 crypto approval transaction to start or update a - * crypto subscription. - * - * This handler is Shield / `TransactionType.shieldSubscriptionApprove` only. - * Delegation-based products (e.g. Money Account) must call - * `startSubscriptionWithCrypto` instead. + * Handles shield subscription crypto approval transactions. * - * @param request - The crypto approval request. - * @param request.productType - The subscription product. Typed as - * `typeof PRODUCT_TYPES.SHIELD` only at the moment (future might support more - * product). - * @param request.txMeta - The transaction metadata. Must have type - * `TransactionType.shieldSubscriptionApprove`. - * @param request.isSponsored - Whether the transaction is sponsored. - * @param request.rewardAccountId - The account ID of the reward subscription - * to link. - * @throws If `productType` is not Shield or `txMeta.type` is not - * `shieldSubscriptionApprove`. + * @param txMeta - The transaction metadata. + * @param isSponsored - Whether the transaction is sponsored. + * @param rewardAccountId - The account ID of the reward subscription to link to the shield subscription. * @returns void */ - async submitSubscriptionCryptoApproval( - request: SubmitSubscriptionCryptoApprovalRequest, + async submitShieldSubscriptionCryptoApproval( + txMeta: TransactionMeta, + isSponsored?: boolean, + rewardAccountId?: CaipAccountId, ): Promise { - const { productType, txMeta, isSponsored, rewardAccountId } = request; - if ( - // Widen for the runtime guard: JS / unsound callers may still pass a - // non-Shield product. - (productType as ProductType) !== PRODUCT_TYPES.SHIELD || - txMeta.type !== TransactionType.shieldSubscriptionApprove - ) { - throw new Error( - SubscriptionControllerErrorMessage.CryptoApprovalRequiresShieldApprove, - ); + if (txMeta.type !== TransactionType.shieldSubscriptionApprove) { + return; } const { chainId, rawTx } = txMeta; @@ -533,33 +463,34 @@ export class SubscriptionController extends StaticIntervalPollingController()< throw new Error('Chain ID or raw transaction not found'); } - const { pricing, lastSelectedPaymentMethod } = this.state; + const { pricing, trialedProducts, lastSelectedPaymentMethod } = this.state; if (!pricing) { throw new Error('Subscription pricing not found'); } if (!lastSelectedPaymentMethod) { throw new Error('Last selected payment method not found'); } - const lastSelectedPaymentMethodForProduct = - lastSelectedPaymentMethod[productType]; - this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodForProduct); + const lastSelectedPaymentMethodShield = + lastSelectedPaymentMethod[PRODUCT_TYPES.SHIELD]; + this.#assertIsPaymentMethodCrypto(lastSelectedPaymentMethodShield); const productPrice = this.#getProductPriceByProductAndPlan( - productType, - lastSelectedPaymentMethodForProduct.plan, + PRODUCT_TYPES.SHIELD, + lastSelectedPaymentMethodShield.plan, ); - // get the latest subscriptions state before computing trial eligibility + const isTrialed = trialedProducts?.includes(PRODUCT_TYPES.SHIELD); + // get the latest subscriptions state to check if the user has an active shield subscription await this.getSubscriptions(); - const isTrialRequested = this.#getIsTrialRequested( - [productType], - lastSelectedPaymentMethodForProduct.plan, + const currentSubscription = this.state.subscriptions.find((subscription) => + subscription.products.some( + (product) => product.name === PRODUCT_TYPES.SHIELD, + ), ); - const currentSubscription = this.getSubscriptionByProduct(productType); this.#assertValidSubscriptionStateForCryptoApproval({ - productType, + productType: PRODUCT_TYPES.SHIELD, }); - // if subscription exists, this transaction is for changing payment method + // if shield subscription exists, this transaction is for changing payment method const isChangePaymentMethod = Boolean(currentSubscription); if (isChangePaymentMethod) { @@ -568,24 +499,23 @@ export class SubscriptionController extends StaticIntervalPollingController()< subscriptionId: (currentSubscription as Subscription).id, chainId, payerAddress: txMeta.txParams.from as Hex, - tokenSymbol: lastSelectedPaymentMethodForProduct.paymentTokenSymbol, + tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, rawTransaction: rawTx as Hex, recurringInterval: productPrice.interval, billingCycles: productPrice.minBillingCycles, }); } else { - const params: StartCryptoSubscriptionRequest = { - products: [productType], - isTrialRequested, + const params = { + products: [PRODUCT_TYPES.SHIELD], + isTrialRequested: !isTrialed, recurringInterval: productPrice.interval, billingCycles: productPrice.minBillingCycles, chainId, payerAddress: txMeta.txParams.from as Hex, - tokenSymbol: lastSelectedPaymentMethodForProduct.paymentTokenSymbol, + tokenSymbol: lastSelectedPaymentMethodShield.paymentTokenSymbol, rawTransaction: rawTx as Hex, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, isSponsored, - useTestClock: lastSelectedPaymentMethodForProduct.useTestClock, + useTestClock: lastSelectedPaymentMethodShield.useTestClock, rewardAccountId, }; await this.startSubscriptionWithCrypto(params); @@ -626,9 +556,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< throw new Error('Price not found'); } - const chainsPaymentInfo = this.#findCryptoPaymentMethod( - request.productType, - CRYPTO_AUTH_METHODS.ERC20_APPROVAL, + const chainsPaymentInfo = pricing.paymentMethods.find( + (paymentMethod) => paymentMethod.type === PAYMENT_TYPES.byCrypto, ); if (!chainsPaymentInfo) { throw new Error('Chains payment info not found'); @@ -693,17 +622,17 @@ export class SubscriptionController extends StaticIntervalPollingController()< /** * Cache the last selected payment method for a specific product. * - * @param request - The request object. - * @param request.product - The product to cache the payment method for. - * @param request.paymentMethod - The payment method to cache. - * @param request.paymentMethod.type - The type of the payment method. - * @param request.paymentMethod.paymentTokenAddress - The payment token address. - * @param request.paymentMethod.plan - The plan of the payment method. + * @param product - The product to cache the payment method for. + * @param paymentMethod - The payment method to cache. + * @param paymentMethod.type - The type of the payment method. + * @param paymentMethod.paymentTokenAddress - The payment token address. + * @param paymentMethod.plan - The plan of the payment method. + * @param paymentMethod.product - The product of the payment method. */ cacheLastSelectedPaymentMethod( - request: CacheLastSelectedPaymentMethodRequest, + product: ProductType, + paymentMethod: CachedLastSelectedPaymentMethod, ): void { - const { product, paymentMethod } = request; if ( paymentMethod.type === PAYMENT_TYPES.byCrypto && (!paymentMethod.paymentTokenAddress || !paymentMethod.paymentTokenSymbol) @@ -749,8 +678,7 @@ export class SubscriptionController extends StaticIntervalPollingController()< * recurringInterval: RecurringInterval.Month, * billingCycles: 1, * } - * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false if the chain does not support sponsorship or the user has already trialed - * @throws If the crypto payment method or chain is missing from pricing + * @returns resolves to true if the sponsorship is supported and intents were submitted successfully, false otherwise */ async submitSponsorshipIntents( request: SubmitSponsorshipIntentsMethodParams, @@ -767,14 +695,10 @@ export class SubscriptionController extends StaticIntervalPollingController()< this.state.lastSelectedPaymentMethod?.[request.products[0]]; this.#assertIsPaymentMethodCrypto(selectedPaymentMethod); - const cryptoAuthMethod = - selectedPaymentMethod.cryptoAuthMethod ?? - CRYPTO_AUTH_METHODS.ERC20_APPROVAL; const isEligibleForTrialedSponsorship = this.#getIsEligibleForTrialedSponsorship( request.chainId, request.products, - cryptoAuthMethod, ); if (!isEligibleForTrialedSponsorship) { return false; @@ -894,8 +818,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< tokenPaymentInfo: TokenPaymentInfo, ): string { const conversionRate = - tokenPaymentInfo.conversionRate?.[ - price.currency as keyof NonNullable + tokenPaymentInfo.conversionRate[ + price.currency as keyof typeof tokenPaymentInfo.conversionRate ]; if (!conversionRate) { throw new Error('Conversion rate not found'); @@ -922,8 +846,8 @@ export class SubscriptionController extends StaticIntervalPollingController()< tokenPaymentInfo: TokenPaymentInfo, ): string { const conversionRate = - tokenPaymentInfo.conversionRate?.[ - price.currency as keyof NonNullable + tokenPaymentInfo.conversionRate[ + price.currency as keyof typeof tokenPaymentInfo.conversionRate ]; if (!conversionRate) { throw new Error('Conversion rate not found'); @@ -1030,19 +954,12 @@ export class SubscriptionController extends StaticIntervalPollingController()< /** * Asserts that the value is a valid crypto payment method. * - * After this assert, `cryptoAuthMethod` and `useTestClock` remain optional - * because persisted cache entries may omit them. - * * @param value - The value to assert. * @throws an error if the value is not a valid crypto payment method. */ #assertIsPaymentMethodCrypto( value: CachedLastSelectedPaymentMethod | undefined, - ): asserts value is CachedLastSelectedPaymentMethod & { - type: typeof PAYMENT_TYPES.byCrypto; - paymentTokenAddress: Hex; - paymentTokenSymbol: string; - } { + ): asserts value is Required { if ( value?.type !== PAYMENT_TYPES.byCrypto || !value.paymentTokenAddress || @@ -1060,19 +977,13 @@ export class SubscriptionController extends StaticIntervalPollingController()< * * @param chainId - The chain ID * @param products - The products to check eligibility for - * @param cryptoAuthMethod - The crypto authorization method of the selected payment method * @returns True if the user is eligible for trialed sponsorship, false otherwise */ #getIsEligibleForTrialedSponsorship( chainId: Hex, products: ProductType[], - cryptoAuthMethod: CryptoAuthMethod, ): boolean { - const isSponsorshipSupported = this.#getChainSupportsSponsorship( - chainId, - products[0], - cryptoAuthMethod, - ); + const isSponsorshipSupported = this.#getChainSupportsSponsorship(chainId); // verify if the user has trialed the provided products before const hasTrialedBefore = this.state.trialedProducts.some((product) => @@ -1082,145 +993,15 @@ export class SubscriptionController extends StaticIntervalPollingController()< return isSponsorshipSupported && !hasTrialedBefore; } - /** - * Whether a trial should be requested for the given products and plan. - * True only when every product has `trialPeriodDays > 0` and has not - * already been trialed. - * - * @param products - The products to check. - * @param plan - The recurring interval to look up pricing for. - * @returns Whether a trial should be requested. - */ - #getIsTrialRequested( - products: ProductType[], - plan: RecurringInterval, - ): boolean { - return products.every((productType) => { - if (this.state.trialedProducts.includes(productType)) { - return false; - } - const productPrice = this.#getProductPriceByProductAndPlan( - productType, - plan, - ); - return productPrice.trialPeriodDays > 0; - }); - } - - #findCryptoPaymentMethod( - productType: ProductType, - cryptoAuthMethod: CryptoAuthMethod, - ): PricingCryptoPaymentMethod | undefined { - const matches: { - method: PricingCryptoPaymentMethod; - explicit: boolean; - }[] = []; - - for (const paymentMethod of this.state.pricing?.paymentMethods ?? []) { - if (paymentMethod.type !== PAYMENT_TYPES.byCrypto) { - continue; - } - - const resolved = this.#resolveCryptoPaymentMethodDefaults(paymentMethod); - if (!resolved) { - continue; - } - - if ( - resolved.cryptoAuthMethod === cryptoAuthMethod && - resolved.products.includes(productType) - ) { - matches.push({ - method: paymentMethod, - explicit: resolved.explicit, - }); - } - } - - if (matches.length === 0) { - return undefined; - } - - const explicitMatches = matches.filter((match) => match.explicit); - const candidates = explicitMatches.length > 0 ? explicitMatches : matches; - - if (candidates.length > 1) { - throw new Error('Multiple matching crypto payment methods found'); - } - - return candidates[0].method; - } - - /** - * Resolves omitted `products` / `cryptoAuthMethod` on a crypto pricing row. - * Legacy Shield + `erc20_approval` defaults apply only when both fields are - * absent. If `products` is present, the list must be non-empty and - * `cryptoAuthMethod` must be explicit; otherwise the row is ignored. - * - * @param paymentMethod - The crypto pricing row. - * @returns Resolved products and auth method, or `undefined` if the row is - * incomplete. - */ - #resolveCryptoPaymentMethodDefaults( - paymentMethod: PricingCryptoPaymentMethod, - ): - | { - products: ProductType[]; - cryptoAuthMethod: CryptoAuthMethod; - explicit: boolean; - } - | undefined { - const productsPresent = paymentMethod.products !== undefined; - const authPresent = paymentMethod.cryptoAuthMethod !== undefined; - - if (!productsPresent && !authPresent) { - return { - products: [PRODUCT_TYPES.SHIELD], - cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, - explicit: false, - }; - } - - if (!paymentMethod.products?.length || !paymentMethod.cryptoAuthMethod) { - return undefined; - } - - return { - products: paymentMethod.products, - cryptoAuthMethod: paymentMethod.cryptoAuthMethod, - explicit: true, - }; - } - - /** - * Whether the given chain supports sponsorship for the product and auth method. - * - * @param chainId - The chain ID - * @param productType - The product type - * @param cryptoAuthMethod - The crypto authorization method to look up - * @returns True if the chain row has sponsorship enabled, false if it is explicitly not sponsored - * @throws If the crypto payment method or chain row is missing from pricing - */ - #getChainSupportsSponsorship( - chainId: Hex, - productType: ProductType, - cryptoAuthMethod: CryptoAuthMethod, - ): boolean { - const cryptoPaymentInfo = this.#findCryptoPaymentMethod( - productType, - cryptoAuthMethod, + #getChainSupportsSponsorship(chainId: Hex): boolean { + const cryptoPaymentInfo = this.state.pricing?.paymentMethods.find( + (paymentMethod) => paymentMethod.type === PAYMENT_TYPES.byCrypto, ); - if (!cryptoPaymentInfo) { - throw new Error('Chains payment info not found'); - } - const chainPaymentInfo = cryptoPaymentInfo.chains?.find( + const isSponsorshipSupported = cryptoPaymentInfo?.chains?.find( (chain) => chain.chainId === chainId, - ); - if (!chainPaymentInfo) { - throw new Error('Invalid chain id'); - } - return Boolean(chainPaymentInfo.isSponsorshipSupported); + )?.isSponsorshipSupported; + return Boolean(isSponsorshipSupported); } /** diff --git a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts index e7d3a5b5ec1..8e7cfc874ea 100644 --- a/packages/subscription-controller/src/SubscriptionService-method-action-types.ts +++ b/packages/subscription-controller/src/SubscriptionService-method-action-types.ts @@ -39,8 +39,7 @@ export type SubscriptionServiceUnCancelSubscriptionAction = { }; /** - * Starts a card-paid subscription checkout session for the requested products - * (e.g. Shield or Money Account Plus). + * Starts a subscription with a card payment method. * * @param request - The start subscription request. * @returns The checkout session response. @@ -55,9 +54,6 @@ export type SubscriptionServiceStartSubscriptionWithCardAction = { * * @param request - The start crypto subscription request. * @returns The created subscription response. - * @throws If `products` is empty. - * @throws If the request does not use exactly one of `rawTransaction` - * (ERC-20 approval) or `delegationHash` (delegation). */ export type SubscriptionServiceStartSubscriptionWithCryptoAction = { type: `SubscriptionService:startSubscriptionWithCrypto`; diff --git a/packages/subscription-controller/src/SubscriptionService-structs.ts b/packages/subscription-controller/src/SubscriptionService-structs.ts index 28fb5c001d9..ddebe1ff6d5 100644 --- a/packages/subscription-controller/src/SubscriptionService-structs.ts +++ b/packages/subscription-controller/src/SubscriptionService-structs.ts @@ -2,32 +2,26 @@ import { array, boolean, enums, - lazy, - literal, nullable, number, - object, optional, string, type, union, } from '@metamask/superstruct'; -import type { Struct } from '@metamask/superstruct'; import { StrictHexStruct, CaipAccountIdStruct } from '@metamask/utils'; import { CANCEL_TYPES, - CRYPTO_AUTH_METHODS, CRYPTO_PAYMENT_METHOD_ERRORS, PAYMENT_TYPES, PRODUCT_TYPES, RECURRING_INTERVALS, SUBSCRIPTION_STATUSES, } from './types.js'; -import type { TokenPaymentInfo } from './types.js'; const ProductTypeStruct = enums(Object.values(PRODUCT_TYPES)); -const CryptoAuthMethodStruct = enums(Object.values(CRYPTO_AUTH_METHODS)); +const PaymentTypeStruct = enums(Object.values(PAYMENT_TYPES)); const RecurringIntervalStruct = enums(Object.values(RECURRING_INTERVALS)); const SubscriptionStatusStruct = enums(Object.values(SUBSCRIPTION_STATUSES)); const CancelTypeStruct = enums(Object.values(CANCEL_TYPES)); @@ -131,52 +125,26 @@ const ProductPricingStruct = type({ prices: array(ProductPriceStruct), }); -const TokenPaymentInfoConversionRateStruct = type({ - usd: string(), -}); - -const TokenPaymentInfoStruct: Struct = lazy(() => - union([ - object({ - symbol: string(), - address: StrictHexStruct, - decimals: number(), - conversionRate: optional(TokenPaymentInfoConversionRateStruct), - isVaultShare: literal(true), - accountantAddress: StrictHexStruct, - sources: optional(array(TokenPaymentInfoStruct)), - }), - object({ - symbol: string(), - address: StrictHexStruct, - decimals: number(), - conversionRate: optional(TokenPaymentInfoConversionRateStruct), - isVaultShare: optional(literal(false)), - sources: optional(array(TokenPaymentInfoStruct)), - }), - ]), -) as Struct; +const TokenPaymentInfoStruct = type({ + symbol: string(), + address: StrictHexStruct, + decimals: number(), + conversionRate: type({ + usd: string(), + }), +}); const ChainPaymentInfoStruct = type({ chainId: StrictHexStruct, paymentAddress: StrictHexStruct, - delegateAddress: optional(StrictHexStruct), tokens: array(TokenPaymentInfoStruct), isSponsorshipSupported: optional(boolean()), }); -const PricingPaymentMethodStruct = union([ - object({ - type: enums([PAYMENT_TYPES.byCard]), - products: optional(array(ProductTypeStruct)), - }), - object({ - type: enums([PAYMENT_TYPES.byCrypto]), - cryptoAuthMethod: optional(CryptoAuthMethodStruct), - products: optional(array(ProductTypeStruct)), - chains: optional(array(ChainPaymentInfoStruct)), - }), -]); +const PricingPaymentMethodStruct = type({ + type: PaymentTypeStruct, + chains: optional(array(ChainPaymentInfoStruct)), +}); export const PricingResponseStruct = type({ products: array(ProductPricingStruct), diff --git a/packages/subscription-controller/src/SubscriptionService.test.ts b/packages/subscription-controller/src/SubscriptionService.test.ts index aa8a241f2e1..55ffc65d880 100644 --- a/packages/subscription-controller/src/SubscriptionService.test.ts +++ b/packages/subscription-controller/src/SubscriptionService.test.ts @@ -757,80 +757,6 @@ describe('SubscriptionService', () => { expect(result).toStrictEqual(response); }); }); - - it('throws when products array is empty', async () => { - const fetchMock = jest.fn(); - const { service } = createService({ fetchMock }); - const request: StartCryptoSubscriptionRequest = { - ...MOCK_CRYPTO_REQUEST, - products: [], - }; - - await expect( - service.startSubscriptionWithCrypto(request), - ).rejects.toThrow( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it.each([ - [ - 'delegation without delegationHash', - { cryptoAuthMethod: 'delegation' as const }, - ], - [ - 'ERC-20 without rawTransaction', - { cryptoAuthMethod: 'erc20_approval' as const }, - ], - ['omitted method without rawTransaction', {}], - [ - 'both rawTransaction and delegationHash', - { rawTransaction: '0xdeadbeef' as const, delegationHash: '0xabc' }, - ], - [ - 'delegation with both fields', - { - cryptoAuthMethod: 'delegation' as const, - rawTransaction: '0xdeadbeef' as const, - delegationHash: '0xabc' as const, - }, - ], - [ - 'ERC-20 with only delegationHash', - { - cryptoAuthMethod: 'erc20_approval' as const, - delegationHash: '0xabc' as const, - }, - ], - ])( - 'rejects %s without posting', - async ( - _case: string, - overrides: { - cryptoAuthMethod?: string; - rawTransaction?: string; - delegationHash?: string; - }, - ) => { - const fetchMock = jest.fn(); - const { service } = createService({ fetchMock }); - const { rawTransaction: _rawTransaction, ...base } = - MOCK_CRYPTO_REQUEST; - // Intentionally invalid combos: runtime still rejects unsound callers. - const request = { - ...base, - ...overrides, - } as StartCryptoSubscriptionRequest; - - await expect( - service.startSubscriptionWithCrypto(request), - ).rejects.toThrow( - SubscriptionServiceErrorMessage.InvalidCryptoAuthCombo, - ); - expect(fetchMock).not.toHaveBeenCalled(); - }, - ); }); describe('getPricing', () => { @@ -839,19 +765,6 @@ describe('SubscriptionService', () => { paymentMethods: [], }; - const mockSpotToken = { - symbol: 'USDC', - address: '0xa9f2867708c727fe250fb0d1fbeb4b4c8e1818e8', - decimals: 9, - conversionRate: { usd: '1.0' }, - }; - - const mockCryptoChain = { - chainId: '0x1', - paymentAddress: '0x00000000000000000000000000000000000000a2', - tokens: [mockSpotToken], - }; - it('should fetch pricing successfully', async () => { const fetchMock = jest.fn(); const { service } = createService({ fetchMock }); @@ -864,61 +777,6 @@ describe('SubscriptionService', () => { expect(result).toStrictEqual(mockPricingResponse); }); - - it('rejects vault share tokens that omit accountantAddress', async () => { - const fetchMock = jest.fn(); - const { service } = createService({ fetchMock }); - - fetchMock.mockResolvedValue( - createMockResponse({ - jsonData: { - products: [], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCrypto, - chains: [ - { - ...mockCryptoChain, - tokens: [ - { - symbol: 'pvmUSD', - address: '0x1C8a336051D2024E318A229d01F9F6CF96efD316', - decimals: 6, - isVaultShare: true, - }, - ], - }, - ], - }, - ], - }, - }), - ); - - await expect(service.getPricing()).rejects.toThrow(/union/u); - }); - - it('rejects card payment methods that include crypto-only fields', async () => { - const fetchMock = jest.fn(); - const { service } = createService({ fetchMock }); - - fetchMock.mockResolvedValue( - createMockResponse({ - jsonData: { - products: [], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCard, - cryptoAuthMethod: 'delegation', - chains: [mockCryptoChain], - }, - ], - }, - }), - ); - - await expect(service.getPricing()).rejects.toThrow(/union/u); - }); }); describe('updatePaymentMethodCard', () => { @@ -1408,213 +1266,6 @@ describe('SubscriptionService', () => { }); }); - describe('multi-product support', () => { - const MOCK_MONEY_ACCOUNT_PRICING_RESPONSE: PricingResponse = { - products: [ - { - name: PRODUCT_TYPES.SHIELD, - prices: [ - { - interval: RECURRING_INTERVALS.month, - unitAmount: 900, - unitDecimals: 2, - currency: 'usd', - trialPeriodDays: 14, - minBillingCycles: 12, - minBillingCyclesForBalance: 1, - }, - ], - }, - { - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - prices: [ - { - interval: RECURRING_INTERVALS.month, - unitAmount: 499, - unitDecimals: 2, - currency: 'usd', - trialPeriodDays: 0, - minBillingCycles: 12, - minBillingCyclesForBalance: 1, - }, - ], - }, - ], - paymentMethods: [ - { - type: PAYMENT_TYPES.byCard, - products: [PRODUCT_TYPES.SHIELD], - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'erc20_approval', - products: [PRODUCT_TYPES.SHIELD], - chains: [ - { - chainId: '0x1', - paymentAddress: '0x00000000000000000000000000000000000000a2', - tokens: [ - { - symbol: 'USDC', - address: '0xa9f2867708c727fe250fb0d1fbeb4b4c8e1818e8', - decimals: 9, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - { - type: PAYMENT_TYPES.byCrypto, - cryptoAuthMethod: 'delegation', - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - chains: [ - { - chainId: '0x8f', - paymentAddress: '0x00000000000000000000000000000000000000a1', - delegateAddress: '0x00000000000000000000000000000000000000c0', - tokens: [ - { - symbol: 'pvmUSD', - address: '0x1C8a336051D2024E318A229d01F9F6CF96efD316', - decimals: 6, - isVaultShare: true, - accountantAddress: - '0x98A45D90E81849a5743241d3ff765F9Fd788206a', - sources: [ - { - symbol: 'mUSD', - address: '0xacA92E438df0B2401fF60dA7E4337B687a2435DA', - decimals: 6, - conversionRate: { usd: '1.0' }, - }, - ], - }, - ], - }, - ], - }, - ], - }; - - it('should validate Money Account pricing responses', async () => { - await withMockSubscriptionService(async ({ service, fetchMock }) => { - fetchMock.mockResolvedValue( - createMockResponse({ jsonData: MOCK_MONEY_ACCOUNT_PRICING_RESPONSE }), - ); - - const result = await service.getPricing(); - - expect(result).toStrictEqual(MOCK_MONEY_ACCOUNT_PRICING_RESPONSE); - }); - }); - - it('should forward delegation-based Money Account crypto subscriptions', async () => { - const delegationRequest: StartCryptoSubscriptionRequest = { - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 12, - chainId: '0x8f', - payerAddress: '0x0000000000000000000000000000000000000001', - tokenSymbol: 'pvmUSD', - cryptoAuthMethod: 'delegation', - delegationHash: '0xabc', - }; - - await withMockSubscriptionService(async ({ service, fetchMock, env }) => { - fetchMock.mockResolvedValue( - createMockResponse({ - jsonData: { - subscriptionId: 'sub_money_account', - status: SUBSCRIPTION_STATUSES.active, - }, - }), - ); - - await service.startSubscriptionWithCrypto(delegationRequest); - - expect(fetchMock).toHaveBeenCalledWith( - SUBSCRIPTION_URL(env, 'subscriptions/crypto'), - { - method: 'POST', - headers: MOCK_HEADERS, - body: JSON.stringify(delegationRequest), - }, - ); - }); - }); - - it('should return dual-product subscription responses', async () => { - const moneyAccountSubscription: Subscription = { - ...MOCK_SUBSCRIPTION, - id: 'sub_money_account', - products: [ - { - name: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - }, - ], - paymentMethod: { - type: PAYMENT_TYPES.byCrypto, - crypto: { - payerAddress: '0x0000000000000000000000000000000000000001', - chainId: '0x8f', - tokenSymbol: 'pvmUSD', - }, - }, - }; - - await withMockSubscriptionService(async ({ service, fetchMock }) => { - fetchMock.mockResolvedValue( - createMockResponse({ - jsonData: { - customerId: 'cus_1', - subscriptions: [MOCK_SUBSCRIPTION, moneyAccountSubscription], - trialedProducts: [PRODUCT_TYPES.SHIELD], - }, - }), - ); - - const result = await service.getSubscriptions(); - - expect(result.subscriptions).toHaveLength(2); - expect(result.trialedProducts).toStrictEqual([PRODUCT_TYPES.SHIELD]); - }); - }); - - it('should return Money Account eligibility responses', async () => { - const moneyAccountEligibility: SubscriptionEligibility = { - product: PRODUCT_TYPES.MONEY_ACCOUNT_PLUS, - canSubscribe: true, - canViewEntryModal: false, - cohorts: [], - assignedCohort: null, - hasAssignedCohortExpired: false, - }; - - await withMockSubscriptionService(async ({ service, fetchMock }) => { - fetchMock.mockResolvedValue( - createMockResponse({ - jsonData: [ - createMockEligibilityResponse(), - moneyAccountEligibility, - ], - }), - ); - - const results = await service.getSubscriptionsEligibilities(); - - expect(results).toStrictEqual([ - createMockEligibilityResponse(), - moneyAccountEligibility, - ]); - }); - }); - }); - describe('error handling', () => { it('rethrows SubscriptionServiceError thrown by fetchQuery without wrapping', async () => { const fetchMock = jest.fn(); diff --git a/packages/subscription-controller/src/SubscriptionService.ts b/packages/subscription-controller/src/SubscriptionService.ts index 11deff00bfd..4a73388e68c 100644 --- a/packages/subscription-controller/src/SubscriptionService.ts +++ b/packages/subscription-controller/src/SubscriptionService.ts @@ -36,7 +36,6 @@ import { SubscriptionStruct, UpdatePaymentMethodCardResponseStruct, } from './SubscriptionService-structs.js'; -import { CRYPTO_AUTH_METHODS } from './types.js'; import type { AssignCohortRequest, BillingPortalResponse, @@ -256,8 +255,7 @@ export class SubscriptionService extends BaseDataService< } /** - * Starts a card-paid subscription checkout session for the requested products - * (e.g. Shield or Money Account Plus). + * Starts a subscription with a card payment method. * * @param request - The start subscription request. * @returns The checkout session response. @@ -292,21 +290,10 @@ export class SubscriptionService extends BaseDataService< * * @param request - The start crypto subscription request. * @returns The created subscription response. - * @throws If `products` is empty. - * @throws If the request does not use exactly one of `rawTransaction` - * (ERC-20 approval) or `delegationHash` (delegation). */ async startSubscriptionWithCrypto( request: StartCryptoSubscriptionRequest, ): Promise { - if (request.products.length === 0) { - throw new SubscriptionServiceError( - SubscriptionControllerErrorMessage.SubscriptionProductsEmpty, - ); - } - - this.#assertValidCryptoAuthCombo(request); - const { profileKey, bearerToken } = await this.#getAuthenticatedContext(); const jsonResponse = await this.#fetchJson({ profileKey, @@ -582,7 +569,7 @@ export class SubscriptionService extends BaseDataService< requestParams as Json, ], staleTime: 0, - gcTime: 0, + cacheTime: 0, queryFn: async () => { const response = await this.#fetch(url.toString(), { method, @@ -624,34 +611,6 @@ export class SubscriptionService extends BaseDataService< } } - /** - * Ensures the request uses exactly one crypto auth method: ERC-20 approval - * (`rawTransaction`, default) or delegation (`delegationHash`). - * - * @param request - The start crypto subscription request. - * @throws If both, neither, or a mismatched combo of auth fields is provided. - */ - #assertValidCryptoAuthCombo(request: StartCryptoSubscriptionRequest): void { - const method = - request.cryptoAuthMethod ?? CRYPTO_AUTH_METHODS.ERC20_APPROVAL; - const hasRawTransaction = Boolean(request.rawTransaction); - const hasDelegationHash = Boolean(request.delegationHash); - const isValidErc20Approval = - method === CRYPTO_AUTH_METHODS.ERC20_APPROVAL && - hasRawTransaction && - !hasDelegationHash; - const isValidDelegation = - method === CRYPTO_AUTH_METHODS.DELEGATION && - hasDelegationHash && - !hasRawTransaction; - - if (!isValidErc20Approval && !isValidDelegation) { - throw new SubscriptionServiceError( - SubscriptionServiceErrorMessage.InvalidCryptoAuthCombo, - ); - } - } - async #getAuthenticatedContext(): Promise<{ profileKey: string; bearerToken: string; diff --git a/packages/subscription-controller/src/constants.ts b/packages/subscription-controller/src/constants.ts index 147daadf6f0..eba4888352d 100644 --- a/packages/subscription-controller/src/constants.ts +++ b/packages/subscription-controller/src/constants.ts @@ -46,7 +46,6 @@ export enum SubscriptionControllerErrorMessage { PaymentMethodNotCrypto = `${controllerName} - Payment method is not crypto`, ProductPriceNotFound = `${controllerName} - Product price not found`, SubscriptionNotValidForCryptoApproval = `${controllerName} - Subscription is not valid for crypto approval`, - CryptoApprovalRequiresShieldApprove = `${controllerName} - Crypto approval is only supported for Shield ERC-20 approve transactions`, LinkRewardsFailed = `${controllerName} - Failed to link rewards`, } @@ -56,7 +55,6 @@ export enum SubscriptionServiceErrorMessage { FailedToUncancelSubscription = 'Failed to uncancel subscription', FailedToStartSubscriptionWithCard = 'Failed to start subscription with card', FailedToStartSubscriptionWithCrypto = 'Failed to start subscription with crypto', - InvalidCryptoAuthCombo = 'Crypto subscription requires exactly one of rawTransaction (erc20_approval) or delegationHash (delegation)', FailedToUpdatePaymentMethodCard = 'Failed to update payment method card', FailedToUpdatePaymentMethodCrypto = 'Failed to update payment method crypto', FailedToGetSubscriptionsEligibilities = 'Failed to get subscriptions eligibilities', diff --git a/packages/subscription-controller/src/index.ts b/packages/subscription-controller/src/index.ts index c3d531ab005..db37dcdee29 100644 --- a/packages/subscription-controller/src/index.ts +++ b/packages/subscription-controller/src/index.ts @@ -14,9 +14,9 @@ export type { SubscriptionControllerGetSubscriptionsEligibilitiesAction, SubscriptionControllerCancelSubscriptionAction, SubscriptionControllerUnCancelSubscriptionAction, - SubscriptionControllerStartSubscriptionWithCardAction, + SubscriptionControllerStartShieldSubscriptionWithCardAction, SubscriptionControllerStartSubscriptionWithCryptoAction, - SubscriptionControllerSubmitSubscriptionCryptoApprovalAction, + SubscriptionControllerSubmitShieldSubscriptionCryptoApprovalAction, SubscriptionControllerGetCryptoApproveTransactionParamsAction, SubscriptionControllerUpdatePaymentMethodAction, SubscriptionControllerGetBillingPortalUrlAction, @@ -44,14 +44,11 @@ export type { CancelType, ISubscriptionService, StartCryptoSubscriptionRequest, - StartDelegationCryptoSubscriptionRequest, - StartErc20CryptoSubscriptionRequest, StartCryptoSubscriptionResponse, StartSubscriptionRequest, StartSubscriptionResponse, GetCryptoApproveTransactionRequest, GetCryptoApproveTransactionResponse, - SubmitSubscriptionCryptoApprovalRequest, SubscriptionCardPaymentMethod, SubscriptionCryptoPaymentMethod, SubscriptionPaymentMethod, @@ -67,14 +64,9 @@ export type { ProductPrice, ProductPricing, TokenPaymentInfo, - SpotTokenPaymentInfo, - VaultTokenPaymentInfo, ChainPaymentInfo, Currency, - CryptoAuthMethod, PricingPaymentMethod, - PricingCardPaymentMethod, - PricingCryptoPaymentMethod, PricingResponse, UpdatePaymentMethodOpts, BillingPortalResponse, @@ -83,7 +75,6 @@ export type { UpdatePaymentMethodCardRequest, UpdatePaymentMethodCardResponse, CachedLastSelectedPaymentMethod, - CacheLastSelectedPaymentMethodRequest, SubmitSponsorshipIntentsMethodParams, Cohort, CohortName, @@ -99,7 +90,6 @@ export { PRODUCT_TYPES, RECURRING_INTERVALS, PAYMENT_TYPES, - CRYPTO_AUTH_METHODS, SubscriptionUserEvent, COHORT_NAMES, BALANCE_CATEGORIES, diff --git a/packages/subscription-controller/src/types.test.ts b/packages/subscription-controller/src/types.test.ts deleted file mode 100644 index b488cfe2698..00000000000 --- a/packages/subscription-controller/src/types.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import type { Hex } from '@metamask/utils'; - -import { - CRYPTO_AUTH_METHODS, - PRODUCT_TYPES, - RECURRING_INTERVALS, -} from './types.js'; -import type { StartCryptoSubscriptionRequest } from './types.js'; - -const SHARED_CRYPTO_REQUEST = { - products: [PRODUCT_TYPES.SHIELD], - isTrialRequested: false, - recurringInterval: RECURRING_INTERVALS.month, - billingCycles: 3, - chainId: '0x1' as Hex, - payerAddress: '0x0000000000000000000000000000000000000001' as Hex, - tokenSymbol: 'USDC', -}; - -function assertStartCryptoSubscriptionRequest( - request: StartCryptoSubscriptionRequest, -): StartCryptoSubscriptionRequest { - return request; -} - -describe('StartCryptoSubscriptionRequest', () => { - it('accepts an ERC-20 approval request without cryptoAuthMethod', () => { - const request = assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - rawTransaction: '0xdeadbeef', - }); - - expect(request.rawTransaction).toBe('0xdeadbeef'); - }); - - it('accepts an ERC-20 approval request with explicit cryptoAuthMethod', () => { - const request = assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, - rawTransaction: '0xdeadbeef', - }); - - expect(request.cryptoAuthMethod).toBe(CRYPTO_AUTH_METHODS.ERC20_APPROVAL); - }); - - it('accepts a delegation request', () => { - const request = assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - products: [PRODUCT_TYPES.MONEY_ACCOUNT_PLUS], - cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, - delegationHash: '0xabc', - }); - - expect(request.delegationHash).toBe('0xabc'); - }); - - it('rejects invalid auth field combinations at compile time', () => { - const bothFields = { - ...SHARED_CRYPTO_REQUEST, - rawTransaction: '0xdeadbeef' as Hex, - delegationHash: '0xabc' as Hex, - }; - // @ts-expect-error ERC-20 and delegation fields together - assertStartCryptoSubscriptionRequest(bothFields); - - // @ts-expect-error neither auth field - assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - }); - - // @ts-expect-error delegation without delegationHash - assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, - }); - - // @ts-expect-error ERC-20 method without rawTransaction - assertStartCryptoSubscriptionRequest({ - ...SHARED_CRYPTO_REQUEST, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, - }); - - const delegationWithRawTransaction = { - ...SHARED_CRYPTO_REQUEST, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.DELEGATION, - rawTransaction: '0xdeadbeef' as Hex, - delegationHash: '0xabc' as Hex, - }; - // @ts-expect-error delegation with rawTransaction - assertStartCryptoSubscriptionRequest(delegationWithRawTransaction); - - const erc20WithDelegationHash = { - ...SHARED_CRYPTO_REQUEST, - cryptoAuthMethod: CRYPTO_AUTH_METHODS.ERC20_APPROVAL, - delegationHash: '0xabc' as Hex, - }; - // @ts-expect-error ERC-20 method with only delegationHash - assertStartCryptoSubscriptionRequest(erc20WithDelegationHash); - - expect(true).toBe(true); - }); -}); diff --git a/packages/subscription-controller/src/types.ts b/packages/subscription-controller/src/types.ts index 5842d42d602..b7504b2b128 100644 --- a/packages/subscription-controller/src/types.ts +++ b/packages/subscription-controller/src/types.ts @@ -1,4 +1,3 @@ -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { CaipAccountId, Hex } from '@metamask/utils'; /** @@ -10,42 +9,12 @@ export type SubscriptionApiError = { statusCode?: number; }; -/** - * Supported subscription products. - */ export const PRODUCT_TYPES = { - /** - * MetaMask Shield. - */ SHIELD: 'shield', - /** - * Money Account Plus (delegation-based crypto billing). - */ - MONEY_ACCOUNT_PLUS: 'money_account_plus', } as const; export type ProductType = (typeof PRODUCT_TYPES)[keyof typeof PRODUCT_TYPES]; -/** - * How a crypto subscription is authorized. - * - * Use `erc20_approval` with `rawTransaction` (e.g. Shield). Use `delegation` - * with `delegationHash` (e.g. Money Account Plus). - */ -export const CRYPTO_AUTH_METHODS = { - /** - * User signs an ERC-20 approve transaction. - */ - ERC20_APPROVAL: 'erc20_approval', - /** - * User authorizes via a stored delegation hash. - */ - DELEGATION: 'delegation', -} as const; - -export type CryptoAuthMethod = - (typeof CRYPTO_AUTH_METHODS)[keyof typeof CRYPTO_AUTH_METHODS]; - export const PAYMENT_TYPES = { byCard: 'card', byCrypto: 'crypto', @@ -189,9 +158,8 @@ export type StartSubscriptionRequest = { useTestClock?: boolean; /** - * Optional CAIP account ID of the rewards account to opt in alongside this - * subscription. Required when the user wants to link rewards during - * subscription creation. + * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. + * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. * * @example { * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', @@ -204,7 +172,7 @@ export type StartSubscriptionResponse = { checkoutSessionUrl: string; }; -type StartCryptoSubscriptionRequestBase = { +export type StartCryptoSubscriptionRequest = { products: ProductType[]; isTrialRequested: boolean; recurringInterval: RecurringInterval; @@ -215,12 +183,12 @@ type StartCryptoSubscriptionRequestBase = { * e.g. "USDC" */ tokenSymbol: string; + rawTransaction: Hex; isSponsored?: boolean; useTestClock?: boolean; /** - * Optional CAIP account ID of the rewards account to opt in alongside this - * subscription. Required when the user wants to link rewards during - * subscription creation. + * The optional ID of the reward subscription to be opt in along with the main `shield` subscription. + * This is required if user wants to opt in to the reward subscription during the `shield` subscription creation. * * @example { * rewardAccountId: 'eip155:1:0x1234567890123456789012345678901234567890', @@ -229,42 +197,6 @@ type StartCryptoSubscriptionRequestBase = { rewardAccountId?: CaipAccountId; }; -/** - * ERC-20 approval crypto subscription request (e.g. Shield). - * - * `cryptoAuthMethod` defaults to `CRYPTO_AUTH_METHODS.ERC20_APPROVAL` when - * omitted. - */ -export type StartErc20CryptoSubscriptionRequest = - StartCryptoSubscriptionRequestBase & { - cryptoAuthMethod?: typeof CRYPTO_AUTH_METHODS.ERC20_APPROVAL; - rawTransaction: Hex; - delegationHash?: never; - }; - -/** - * Delegation-based crypto subscription request (e.g. Money Account Plus). - */ -export type StartDelegationCryptoSubscriptionRequest = - StartCryptoSubscriptionRequestBase & { - cryptoAuthMethod: typeof CRYPTO_AUTH_METHODS.DELEGATION; - delegationHash: Hex; - rawTransaction?: never; - }; - -/** - * Request to start a crypto subscription. - * - * Discriminated union of ERC-20 approval vs delegation. Provide - * `rawTransaction` for ERC-20 approval (the default when `cryptoAuthMethod` is - * omitted), or `delegationHash` with `cryptoAuthMethod: 'delegation'`. - * Combining or omitting both is a type error, and is also rejected at runtime - * by `startSubscriptionWithCrypto`. - */ -export type StartCryptoSubscriptionRequest = - | StartErc20CryptoSubscriptionRequest - | StartDelegationCryptoSubscriptionRequest; - export type StartCryptoSubscriptionResponse = { subscriptionId: string; status: SubscriptionStatus; @@ -313,7 +245,7 @@ export type ProductPricing = { prices: ProductPrice[]; }; -type TokenPaymentInfoBase = { +export type TokenPaymentInfo = { symbol: string; address: Hex; decimals: number; @@ -322,47 +254,14 @@ type TokenPaymentInfoBase = { usd: '1.0', }, */ - conversionRate?: { + conversionRate: { usd: string; }; - /** - * Source tokens that can be converted into this settlement token. - */ - sources?: TokenPaymentInfo[]; }; -/** - * Spot (non-vault) settlement token. Priced via `conversionRate` when provided. - * `accountantAddress` is not present on this variant. - */ -export type SpotTokenPaymentInfo = TokenPaymentInfoBase & { - isVaultShare?: false; -}; - -/** - * Yield-bearing vault share priced via an accountant rate. - */ -export type VaultTokenPaymentInfo = TokenPaymentInfoBase & { - isVaultShare: true; - /** - * Veda accountant address used to value this vault share. - */ - accountantAddress: Hex; -}; - -/** - * A settlement token in a pricing chain. Discriminated by `isVaultShare`: - * vault shares require `accountantAddress`; spot tokens omit it. - */ -export type TokenPaymentInfo = SpotTokenPaymentInfo | VaultTokenPaymentInfo; - export type ChainPaymentInfo = { chainId: Hex; paymentAddress: Hex; - /** - * Delegate address clients authorize when using the delegation auth method. - */ - delegateAddress?: Hex; tokens: TokenPaymentInfo[]; /** * Whether the chain supports sponsorship for the trialed subscription approval transaction. @@ -371,39 +270,11 @@ export type ChainPaymentInfo = { isSponsorshipSupported?: boolean; }; -export type PricingCardPaymentMethod = { - type: Extract; - /** - * Products that support this payment method. - */ - products?: ProductType[]; -}; - -export type PricingCryptoPaymentMethod = { - type: Extract; - /** - * Crypto authorization method. Omitted together with `products` on persisted - * pre-multi-product pricing rows; those rows are treated as Shield + `erc20_approval`. - * If `products` is set, this field must be explicit. - */ - cryptoAuthMethod?: CryptoAuthMethod; - /** - * Products that support this payment method. Omitted together with - * `cryptoAuthMethod` on persisted pre-multi-product pricing rows (treated as Shield). - * If present, must be non-empty and paired with an explicit `cryptoAuthMethod`. - */ - products?: ProductType[]; +export type PricingPaymentMethod = { + type: PaymentType; chains?: ChainPaymentInfo[]; }; -/** - * A pricing payment-method row. Discriminated by `type`: card rows have no - * crypto fields; crypto rows may include `cryptoAuthMethod` and `chains`. - */ -export type PricingPaymentMethod = - | PricingCardPaymentMethod - | PricingCryptoPaymentMethod; - export type PricingResponse = { products: ProductPricing[]; paymentMethods: PricingPaymentMethod[]; @@ -439,30 +310,6 @@ export type GetCryptoApproveTransactionResponse = { chainId: Hex; }; -/** - * Request to submit a Shield ERC-20 crypto approval transaction. - */ -export type SubmitSubscriptionCryptoApprovalRequest = { - /** - * The subscription product. Typed as `typeof PRODUCT_TYPES.SHIELD` only at - * the moment (future might support more product). - */ - productType: typeof PRODUCT_TYPES.SHIELD; - /** - * The transaction metadata. Must have type - * `TransactionType.shieldSubscriptionApprove`. - */ - txMeta: TransactionMeta; - /** - * Whether the transaction is sponsored. - */ - isSponsored?: boolean; - /** - * The account ID of the reward subscription to link. - */ - rewardAccountId?: CaipAccountId; -}; - export const COHORT_NAMES = { POST_TX: 'post_tx', WALLET_HOME: 'wallet_home', @@ -543,13 +390,6 @@ export type ISubscriptionService = { unCancelSubscription(request: { subscriptionId: string; }): Promise; - /** - * Starts a card-paid subscription checkout session for the requested products - * (e.g. Shield or Money Account Plus). - * - * @param request - The start subscription request. - * @returns The checkout session response. - */ startSubscriptionWithCard( request: StartSubscriptionRequest, ): Promise; @@ -650,25 +490,6 @@ export type CachedLastSelectedPaymentMethod = { paymentTokenSymbol?: string; plan: RecurringInterval; useTestClock?: boolean; - /** - * Crypto authorization method. Omitted on persisted cache entries written - * before this field existed; treat as `erc20_approval` when missing. - */ - cryptoAuthMethod?: CryptoAuthMethod; -}; - -/** - * Request to cache the last selected payment method for a product. - */ -export type CacheLastSelectedPaymentMethodRequest = { - /** - * The product to cache the payment method for. - */ - product: ProductType; - /** - * The payment method to cache. - */ - paymentMethod: CachedLastSelectedPaymentMethod; }; /** diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 49c3583c997..f25937e435a 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,11 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Bump `@metamask/core-backend` from `^8.1.1` to `^8.1.2` ([#9886](https://github.com/MetaMask/core/pull/9886)) -- Extend `isSimulationEnabled` option to accept an optional `TransactionMeta` argument, enabling callback consumers to inspect the relevant transaction ([#9800](https://github.com/MetaMask/core/pull/9800)) - ## [69.5.2] ### Changed diff --git a/packages/transaction-controller/package.json b/packages/transaction-controller/package.json index a8b85ca3d31..517730663e3 100644 --- a/packages/transaction-controller/package.json +++ b/packages/transaction-controller/package.json @@ -66,7 +66,7 @@ "@metamask/approval-controller": "^9.0.2", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/core-backend": "^8.1.2", + "@metamask/core-backend": "^8.1.1", "@metamask/gas-fee-controller": "^26.3.1", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", diff --git a/packages/transaction-controller/src/TransactionController.test.ts b/packages/transaction-controller/src/TransactionController.test.ts index 8e99750bbc0..dbbc422a420 100644 --- a/packages/transaction-controller/src/TransactionController.test.ts +++ b/packages/transaction-controller/src/TransactionController.test.ts @@ -2711,30 +2711,6 @@ describe('TransactionController', () => { }); }); - it('passes the transaction meta to the isSimulationEnabled callback', async () => { - const isSimulationEnabled = jest.fn().mockReturnValue(true); - - const { controller } = setupController({ - options: { isSimulationEnabled }, - }); - - const { transactionMeta } = await controller.addTransaction( - { - from: ACCOUNT_MOCK, - to: ACCOUNT_MOCK, - }, - { - networkClientId: NETWORK_CLIENT_ID_MOCK, - }, - ); - - await flushPromises(); - - expect(isSimulationEnabled).toHaveBeenCalledWith( - expect.objectContaining({ id: transactionMeta.id }), - ); - }); - it('unless approval not required', async () => { getBalanceChangesMock.mockResolvedValueOnce({ simulationData: SIMULATION_DATA_RESULT_MOCK, diff --git a/packages/transaction-controller/src/TransactionController.ts b/packages/transaction-controller/src/TransactionController.ts index aaf788a8052..f6aa3366391 100644 --- a/packages/transaction-controller/src/TransactionController.ts +++ b/packages/transaction-controller/src/TransactionController.ts @@ -374,7 +374,7 @@ export type TransactionControllerOptions = { isFirstTimeInteractionEnabled?: () => boolean; /** Whether new transactions will be automatically simulated. */ - isSimulationEnabled?: (transactionMeta?: TransactionMeta) => boolean; + isSimulationEnabled?: () => boolean; /** Whether timeout checking is enabled for a transaction. */ isTimeoutEnabled?: (transactionMeta: TransactionMeta) => boolean; @@ -750,7 +750,7 @@ export class TransactionController extends BaseController< readonly #isFirstTimeInteractionEnabled: () => boolean; - readonly #isSimulationEnabled: (transactionMeta?: TransactionMeta) => boolean; + readonly #isSimulationEnabled: () => boolean; readonly #isSwapsDisabled: boolean; @@ -4039,7 +4039,7 @@ export class TransactionController extends BaseController< validateTxParams(transactionMeta.txParams); } - if (!skipResimulateCheck && this.#isSimulationEnabled(transactionMeta)) { + if (!skipResimulateCheck && this.#isSimulationEnabled()) { resimulateResponse = shouldResimulate( originalTransactionMeta, transactionMeta, @@ -4109,7 +4109,7 @@ export class TransactionController extends BaseController< this.#simulationRequestTokens.set(transactionId, simulationRequestToken); try { - const isSimulationEnabled = this.#isSimulationEnabled(transactionMeta); + const isSimulationEnabled = this.#isSimulationEnabled(); const isBalanceChangesSkipped = this.#isBalanceChangesSkipped(transactionMeta); @@ -4327,7 +4327,7 @@ export class TransactionController extends BaseController< await updateGas({ isCustomNetwork, - isSimulationEnabled: this.#isSimulationEnabled(transactionMeta), + isSimulationEnabled: this.#isSimulationEnabled(), getSimulationConfig: this.#getSimulationConfig, messenger: this.messenger, txMeta: transactionMeta, diff --git a/packages/transaction-controller/src/utils/batch.ts b/packages/transaction-controller/src/utils/batch.ts index 16e5c0c4bc4..5978fbf2778 100644 --- a/packages/transaction-controller/src/utils/batch.ts +++ b/packages/transaction-controller/src/utils/batch.ts @@ -85,7 +85,7 @@ type AddTransactionBatchRequest = { ) => PendingTransactionTracker; getSimulationConfig: GetSimulationConfig; getTransaction: (id: string) => TransactionMeta; - isSimulationEnabled: (transactionMeta?: TransactionMeta) => boolean; + isSimulationEnabled: () => boolean; messenger: TransactionControllerMessenger; publishBatchHook?: PublishBatchHook; publishTransaction: (transactionMeta: TransactionMeta) => Promise; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 08d240098e1..1841e2ff895 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,33 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [26.4.1] - -### Changed - -- Bump `@metamask/assets-controller` from `^13.1.4` to `^14.0.0` ([#9923](https://github.com/MetaMask/core/pull/9923)) - -## [26.4.0] - -### Changed - -- The `payStrategies.relay.validationEnabled` feature flag (in `confirmations_pay_extended`) is now an object `{ default?: boolean; transactionTypes?: { [type in TransactionType]?: boolean } }` instead of a boolean, adding per-`TransactionType` overrides that match nested transactions ([#9888](https://github.com/MetaMask/core/pull/9888)) - -### Fixed - -- Fix quote simulation for Polymarket Predict withdrawals ([#9891](https://github.com/MetaMask/core/pull/9891)) +### Added -## [26.3.1] +- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ### Changed -- Bump `@metamask/assets-controller` from `^13.1.2` to `^13.1.4` ([#9873](https://github.com/MetaMask/core/pull/9873), [#9886](https://github.com/MetaMask/core/pull/9886)) -- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) -- Bump `@metamask/assets-controllers` from `^111.1.0` to `^111.1.1` ([#9886](https://github.com/MetaMask/core/pull/9886)) +- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823), [#9853](https://github.com/MetaMask/core/pull/9853)) ### Fixed -- Read the `stableTokens` remote feature flag in `getStablecoins` instead of `stable-tokens` ([#9885](https://github.com/MetaMask/core/pull/9885)) +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval. Skipped results (vaulting disabled) are not retained, so a later enablement can retry the same payout hash. ([#9849](https://github.com/MetaMask/core/pull/9849), [#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [26.3.0] @@ -1456,10 +1444,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.4.1...HEAD -[26.4.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.4.0...@metamask/transaction-pay-controller@26.4.1 -[26.4.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.3.1...@metamask/transaction-pay-controller@26.4.0 -[26.3.1]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.3.0...@metamask/transaction-pay-controller@26.3.1 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.3.0...HEAD [26.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.2.3...@metamask/transaction-pay-controller@26.3.0 [26.2.3]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.2.2...@metamask/transaction-pay-controller@26.2.3 [26.2.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@26.2.1...@metamask/transaction-pay-controller@26.2.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index d3eddbbaf92..eec01336006 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "26.4.1", + "version": "26.3.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -59,14 +59,15 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^14.0.0", - "@metamask/assets-controllers": "^111.1.1", + "@metamask/assets-controller": "^13.1.2", + "@metamask/assets-controllers": "^111.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", "@metamask/gas-fee-controller": "^26.3.1", "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/money-account-utils": "^1.1.0", "@metamask/network-controller": "^35.0.1", "@metamask/ramps-controller": "^20.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 14a91436fa2..09e0ae9eb78 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -49,6 +49,38 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { handler: TransactionPayController['updateFiatPayment']; }; +/** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { + type: `TransactionPayController:submitMoneyAccountVaultDeposit`; + handler: TransactionPayController['submitMoneyAccountVaultDeposit']; +}; + +/** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction = { + type: `TransactionPayController:submitMoneyAccountVaultWithdraw`; + handler: TransactionPayController['submitMoneyAccountVaultWithdraw']; +}; + /** * Gets the delegation transaction for a given transaction. * @@ -144,6 +176,8 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction + | TransactionPayControllerSubmitMoneyAccountVaultDepositAction + | TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetFiatOptionsAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 467f6406ab2..e173155c555 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -16,6 +16,8 @@ import type { UpdateTransactionDataCallback, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -31,6 +33,8 @@ jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); jest.mock('./utils/feature-flags'); +jest.mock('./utils/ma-vault-payout'); +jest.mock('./utils/ma-vault-withdraw'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -50,6 +54,12 @@ describe('TransactionPayController', () => { ); const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); + const submitMoneyAccountVaultDepositFromPayoutMock = jest.mocked( + submitMoneyAccountVaultDepositFromPayout, + ); + const submitMoneyAccountVaultWithdrawUtilMock = jest.mocked( + submitMoneyAccountVaultWithdrawUtil, + ); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -106,6 +116,205 @@ describe('TransactionPayController', () => { }); }); + describe('Money Account vault actions', () => { + const moneyAccountAddress = + '0x1111111111111111111111111111111111111111' as Hex; + const transactionHash = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const recipient = '0x2222222222222222222222222222222222222222' as Hex; + + it('exposes the payout deposit action through the messenger', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + createController(); + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultDeposit', + { + moneyAccountAddress, + transactionHash, + }, + ); + + expect(submitMoneyAccountVaultDepositFromPayoutMock).toHaveBeenCalledWith( + { moneyAccountAddress, transactionHash }, + messenger, + ); + expect(result).toStrictEqual({ transactionHash }); + }); + + it('deduplicates concurrent payout deposit actions by transaction hash', async () => { + let resolveSubmit: + | ((value: { transactionHash?: Hex }) => void) + | undefined; + submitMoneyAccountVaultDepositFromPayoutMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = controller.submitMoneyAccountVaultDeposit(request); + const second = controller.submitMoneyAccountVaultDeposit(request); + resolveSubmit?.({ transactionHash }); + + expect(await first).toStrictEqual({ transactionHash }); + expect(await second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('returns the prior result on retry after a successful deposit without resubmitting', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = await controller.submitMoneyAccountVaultDeposit(request); + const second = await controller.submitMoneyAccountVaultDeposit(request); + + expect(first).toStrictEqual({ transactionHash }); + expect(second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('retries after a failed deposit', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockRejectedValueOnce(new Error('vault failed')) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).rejects.toThrow('vault failed'); + + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + + it('retries after a skipped deposit once vaulting is enabled', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockResolvedValueOnce({ skipped: true }) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ skipped: true }); + + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + + it('exposes the exact-out withdraw action through the messenger', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultWithdraw', + request, + ); + + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledWith( + request, + messenger, + ); + expect(result).toStrictEqual({ batchId: '0x123' }); + }); + + it('deduplicates concurrent withdraw actions by request ID', async () => { + let resolveSubmit: ((value: { batchId: Hex }) => void) | undefined; + submitMoneyAccountVaultWithdrawUtilMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + const first = controller.submitMoneyAccountVaultWithdraw(request); + const second = controller.submitMoneyAccountVaultWithdraw(request); + resolveSubmit?.({ batchId: '0x123' }); + + expect(await first).toStrictEqual({ batchId: '0x123' }); + expect(await second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + + it('returns the same batchId on retry after approval is created without resubmitting', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + const first = await controller.submitMoneyAccountVaultWithdraw(request); + const second = await controller.submitMoneyAccountVaultWithdraw(request); + + expect(first).toStrictEqual({ batchId: '0x123' }); + expect(second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + + it('retries withdraw after a failed batch setup', async () => { + submitMoneyAccountVaultWithdrawUtilMock + .mockRejectedValueOnce(new Error('batch failed')) + .mockResolvedValueOnce({ batchId: '0x123' as Hex }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + await expect( + controller.submitMoneyAccountVaultWithdraw(request), + ).rejects.toThrow('batch failed'); + + expect( + await controller.submitMoneyAccountVaultWithdraw(request), + ).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2); + }); + }); + describe('updatePaymentToken', () => { it('calls util', () => { createController().updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0f35951e683..aa6c777a860 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -29,6 +29,11 @@ import type { UpdatePaymentTokenRequest, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; +import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -45,6 +50,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'polymarketGetDepositWalletAddress', 'polymarketSubmitDepositWalletBatch', 'setTransactionConfig', + 'submitMoneyAccountVaultDeposit', + 'submitMoneyAccountVaultWithdraw', 'updateFiatPayment', 'updatePaymentToken', ] as const; @@ -87,6 +94,27 @@ export class TransactionPayController extends BaseController< readonly #resolveSourceAmount?: ResolveSourceAmountCallback; + /** + * In-flight and completed payout vault deposits, keyed by payout tx hash. + * Completed successes stay cached for the controller lifetime so webhook + * replays / retries do not re-submit. Preferable to persisted state here + * because vaulting is idempotent per process and avoids a state migration. + */ + readonly #vaultDepositRequests = new Map< + string, + Promise + >(); + + /** + * In-flight and completed withdraw batch setups, keyed by requestId. + * Successful `addTransactionBatch` results stay cached for the controller + * lifetime so a second call cannot open another approval for the same id. + */ + readonly #vaultWithdrawRequests = new Map< + string, + Promise<{ batchId: `0x${string}` }> + >(); + constructor({ fiatOptions, getAmountData, @@ -215,6 +243,75 @@ export class TransactionPayController extends BaseController< }); } + /** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. + */ + submitMoneyAccountVaultDeposit( + request: SubmitMoneyAccountVaultDepositRequest, + ): Promise { + const key = request.transactionHash.toLowerCase(); + const current = this.#vaultDepositRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultDepositFromPayout( + request, + this.messenger, + ) + .then((result) => { + if (result.skipped) { + this.#vaultDepositRequests.delete(key); + } + return result; + }) + .catch((error: unknown) => { + this.#vaultDepositRequests.delete(key); + throw error; + }); + this.#vaultDepositRequests.set(key, pending); + return pending; + } + + /** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ + submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + ): Promise<{ batchId: `0x${string}` }> { + const key = request.requestId; + const current = this.#vaultWithdrawRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultWithdrawUtil( + request, + this.messenger, + ).catch((error: unknown) => { + this.#vaultWithdrawRequests.delete(key); + throw error; + }); + this.#vaultWithdrawRequests.set(key, pending); + return pending; + } + /** * Gets the delegation transaction for a given transaction. * diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 1d52593f72e..0c430b4f470 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -39,9 +39,14 @@ export type { TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetTransactionConfigAction, + TransactionPayControllerSubmitMoneyAccountVaultDepositAction, + TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; +export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +export type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; +export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook.js'; diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts index 9bf58a51114..74e47de23b2 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts @@ -89,6 +89,19 @@ describe('FiatStrategy', () => { ).rejects.toThrow('Fiat: Missing transaction hash'); }); + it('returns skipped when vault deposit is disabled', async () => { + submitFiatQuotesMock.mockResolvedValue({ skipped: true }); + + const result = await new FiatStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayControllerMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(result).toStrictEqual({ skipped: true }); + }); + it('preserves nested Post-Ramp and Vault prefixes', async () => { submitFiatQuotesMock.mockRejectedValue( new Error('Post-Ramp: Direct mUSD: Vault: Missing transaction hash'), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts index b6444f3c286..989c87145b7 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts @@ -24,6 +24,10 @@ export class FiatStrategy implements PayStrategy { try { const result = await submitFiatQuotes(request); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index e1efce2806e..68ca081c666 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -18,6 +18,7 @@ import type { import { prefixError } from '../../utils/error-prefix.js'; import { getFiatVaultDisabled } from '../../utils/feature-flags.js'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; +import type { SubmitMoneyAccountVaultDepositResult } from '../../utils/ma-vault-deposit.js'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; import { MUSD_MONAD_FIAT_ASSET } from './constants.js'; import type { FiatQuote } from './types.js'; @@ -130,7 +131,7 @@ export async function submitDirectMusdAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const { messenger, transaction } = request; try { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 2c8310931c0..cbabadb992a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -1326,7 +1326,7 @@ describe('submitFiatQuotes', () => { ); }); - it('skips the vault batch and returns an empty hash when vaultDisabled is enabled', async () => { + it('skips the vault batch and returns skipped when vaultDisabled is enabled', async () => { const { callMock, request } = getRequest({ quotes: [ getFiatQuoteMock({ @@ -1378,7 +1378,7 @@ describe('submitFiatQuotes', () => { const result = await submitFiatQuotes(request); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalledWith( 'TransactionPayController:getAmountData', expect.anything(), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 19042c2a3ee..e546e2b85a4 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -133,6 +133,10 @@ export async function submitFiatQuotes( request, }); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } @@ -239,7 +243,7 @@ async function submitRelayAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise<{ skipped?: true; transactionHash?: Hex }> { const { messenger, quotes, transaction } = request; const transactionId = transaction.id; diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts index 671bab9f279..951714bf7e0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.test.ts @@ -1,6 +1,3 @@ -import { Interface } from '@ethersproject/abi'; -import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { @@ -17,9 +14,6 @@ import { } from './constants.js'; import { applyPolymarketDepositWalletOverrides, - buildPolymarketDepositWalletSimulation, - getPredictWithdrawSafeAddress, - isPredictWithdraw, submitPolymarketWithdraw, sweepPolymarketDepositWallet, } from './withdraw.js'; @@ -283,169 +277,4 @@ describe('Polymarket withdraw', () => { }); }); }); - - describe('isPredictWithdraw', () => { - const withdrawTransaction = { - type: TransactionType.predictWithdraw, - } as TransactionMeta; - - it('returns true for a post-quote predict withdraw', () => { - expect( - isPredictWithdraw( - { isPostQuote: true } as QuoteRequest, - withdrawTransaction, - ), - ).toBe(true); - }); - - it('returns false when not post-quote', () => { - expect(isPredictWithdraw({} as QuoteRequest, withdrawTransaction)).toBe( - false, - ); - }); - - it('returns false when the transaction is not a predict withdraw', () => { - expect( - isPredictWithdraw( - { isPostQuote: true } as QuoteRequest, - { - type: TransactionType.simpleSend, - } as TransactionMeta, - ), - ).toBe(false); - }); - }); - - describe('getPredictWithdrawSafeAddress', () => { - const withdrawTransaction = { - type: TransactionType.predictWithdraw, - } as TransactionMeta; - const depositSteps = [ - { id: 'deposit', kind: 'transaction', items: [] }, - ] as unknown as RelayQuote['steps']; - const swapSteps = [ - { id: 'swap', kind: 'transaction', items: [] }, - ] as unknown as RelayQuote['steps']; - - it('returns refundTo for a deposit-style predict withdraw', () => { - expect( - getPredictWithdrawSafeAddress( - { isPostQuote: true, refundTo: DEPOSIT_WALLET_MOCK } as QuoteRequest, - depositSteps, - withdrawTransaction, - ), - ).toBe(DEPOSIT_WALLET_MOCK); - }); - - it('returns undefined for the deposit-wallet variant (handled by its own simulation)', () => { - expect( - getPredictWithdrawSafeAddress( - { - isPostQuote: true, - refundTo: DEPOSIT_WALLET_MOCK, - isPolymarketDepositWallet: true, - } as QuoteRequest, - depositSteps, - withdrawTransaction, - ), - ).toBeUndefined(); - }); - - it('returns undefined for swap-only routes (no deposit step)', () => { - expect( - getPredictWithdrawSafeAddress( - { isPostQuote: true, refundTo: DEPOSIT_WALLET_MOCK } as QuoteRequest, - swapSteps, - withdrawTransaction, - ), - ).toBeUndefined(); - }); - - it('returns undefined when not a predict withdraw', () => { - expect( - getPredictWithdrawSafeAddress( - { isPostQuote: true, refundTo: DEPOSIT_WALLET_MOCK } as QuoteRequest, - depositSteps, - { type: TransactionType.simpleSend } as TransactionMeta, - ), - ).toBeUndefined(); - }); - }); - - describe('buildPolymarketDepositWalletSimulation', () => { - const RELAY_DEPOSIT_ADDRESS_MOCK = - '0x1234567890123456789012345678901234567890' as Hex; - - it('simulates the real approve + unwrap batch from the deposit wallet', async () => { - const simulation = await buildPolymarketDepositWalletSimulation( - buildQuote(), - EOA_MOCK, - messenger, - ); - - expect(polymarketGetDepositWalletAddressMock).toHaveBeenCalledWith({ - eoa: EOA_MOCK, - }); - expect(simulation.transactions).toHaveLength(2); - - const [approve, unwrap] = simulation.transactions; - - expect(approve.from).toBe(DEPOSIT_WALLET_MOCK); - expect(approve.to).toBe(POLYGON_PUSD_ADDRESS); - expect(approve.value).toBe('0x0'); - - expect(unwrap.from).toBe(DEPOSIT_WALLET_MOCK); - expect(unwrap.to).toBe(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON); - expect(unwrap.value).toBe('0x0'); - }); - - it('encodes the approve for the offramp and the amount from the quote', async () => { - const simulation = await buildPolymarketDepositWalletSimulation( - buildQuote(), - EOA_MOCK, - messenger, - ); - - const [approve, unwrap] = simulation.transactions; - - // approve(offramp, 1000000) - const approveInterface = new Interface([ - 'function approve(address spender, uint256 amount)', - ]); - const decodedApprove = approveInterface.decodeFunctionData( - 'approve', - approve.data as Hex, - ); - expect(decodedApprove[0].toLowerCase()).toBe( - POLYMARKET_COLLATERAL_OFFRAMP_POLYGON.toLowerCase(), - ); - expect(decodedApprove[1].toString()).toBe(SOURCE_AMOUNT_RAW_MOCK); - - // unwrap(USDC.e, relayDepositAddress, 1000000) - const unwrapInterface = new Interface([ - 'function unwrap(address asset, address recipient, uint256 amount)', - ]); - const decodedUnwrap = unwrapInterface.decodeFunctionData( - 'unwrap', - unwrap.data as Hex, - ); - expect(decodedUnwrap[0].toLowerCase()).toBe( - POLYGON_USDCE_ADDRESS.toLowerCase(), - ); - expect(decodedUnwrap[1].toLowerCase()).toBe( - RELAY_DEPOSIT_ADDRESS_MOCK.toLowerCase(), - ); - expect(decodedUnwrap[2].toString()).toBe(SOURCE_AMOUNT_RAW_MOCK); - }); - - it('throws when the Relay quote has no deposit step', async () => { - await expect( - buildPolymarketDepositWalletSimulation( - buildQuote({ steps: [] } as Partial), - EOA_MOCK, - messenger, - ), - ).rejects.toThrow('Relay quote has no deposit step'); - }); - }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts index 7591200dbb2..a234c1c4ac7 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/polymarket/withdraw.ts @@ -1,8 +1,3 @@ -import { - TransactionType, - hasTransactionType, -} from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; @@ -18,7 +13,6 @@ import type { TransactionPayQuote, } from '../../../types.js'; import { getLiveTokenBalance } from '../../../utils/token.js'; -import type { QuoteSimulation } from '../../../utils/validation.js'; import type { RelayQuote, RelayQuoteRequest, @@ -41,96 +35,6 @@ import { const log = createModuleLogger(projectLogger, 'polymarket-withdraw'); -type RelayQuoteStep = RelayQuote['steps'][number]; - -/** - * Whether a quote request + transaction represent a Predict withdraw post-quote - * flow. - * - * @param request - Quote request. - * @param transaction - Original transaction metadata. - * @returns True when this is a Predict withdraw post-quote flow. - */ -export function isPredictWithdraw( - request: QuoteRequest, - transaction: TransactionMeta, -): boolean { - return Boolean( - request.isPostQuote && - hasTransactionType(transaction, [TransactionType.predictWithdraw]), - ); -} - -/** - * Resolve the Polymarket Safe proxy address that must act as `msg.sender` when - * estimating gas for the withdraw leg of a Safe-based Predict withdraw. - * - * Only applies to the Safe variant on deposit-style Relay routes: the source - * token lives in the Safe (carried as `request.refundTo`), and swap-only routes - * keep the EOA `from` because DEX aggregators reject contract callers. - * - * @param request - Quote request. - * @param steps - Relay quote steps (used to detect a deposit-style route). - * @param transaction - Original transaction metadata. - * @returns The Safe proxy address, or `undefined` when the default EOA `from` - * should be used. - */ -export function getPredictWithdrawSafeAddress( - request: QuoteRequest, - steps: RelayQuoteStep[], - transaction: TransactionMeta, -): Hex | undefined { - if (!isPredictWithdraw(request, transaction)) { - return undefined; - } - - // Deposit-wallet withdraws are validated via their own simulation builder; - // `request.refundTo` is not the deposit wallet for that variant. - if (request.isPolymarketDepositWallet) { - return undefined; - } - - const hasDepositStep = steps.some((step) => step.id === 'deposit'); - - if (!hasDepositStep) { - return undefined; - } - - return request.refundTo; -} - -/** - * Build the simulation for a Polymarket deposit-wallet Predict withdraw, - * mirroring the approve + unwrap batch broadcast by {@link submitPolymarketWithdraw} - * so validation checks the deposit wallet holds enough pUSD. - * - * @param quote - Relay quote (source amount + deposit step). - * @param from - The user EOA that owns the deposit wallet. - * @param messenger - Controller messenger. - * @returns Simulation for the approve + unwrap batch from the deposit wallet. - */ -export async function buildPolymarketDepositWalletSimulation( - quote: TransactionPayQuote, - from: Hex, - messenger: TransactionPayControllerMessenger, -): Promise { - const depositWalletAddress = await getDepositWalletAddress(messenger, from); - const relayDepositAddress = extractRelayDepositAddress(quote.original); - const amount = BigInt(quote.sourceAmount.raw); - - return { - transactions: buildDepositWalletUnwrapCalls( - relayDepositAddress, - amount, - ).map((call) => ({ - from: depositWalletAddress, - to: call.target, - data: call.data, - value: '0x0', - })), - }; -} - export async function applyPolymarketDepositWalletOverrides( body: RelayQuoteRequest, request: QuoteRequest, @@ -172,9 +76,22 @@ export async function submitPolymarketWithdraw( const result = await submitDepositWalletBatch(messenger, { eoa: from, depositWallet: depositWalletAddress, - calls: buildDepositWalletUnwrapCalls(relayDepositAddress, amount).map( - (call) => ({ ...call, value: '0' }), - ), + calls: [ + { + target: POLYGON_PUSD_ADDRESS, + value: '0', + data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), + }, + { + target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, + value: '0', + data: encodeUnwrap({ + asset: POLYGON_USDCE_ADDRESS, + recipient: relayDepositAddress, + amount, + }), + }, + ], }); return { ...result, preSubmitUsdceBalance }; @@ -369,33 +286,3 @@ function extractRelayDepositAddress(relayQuote: RelayQuote): Hex { return extractErc20TransferRecipient(depositCallData); } - -/** - * Build the approve + unwrap batch that moves a deposit wallet's pUSD to the - * Relay deposit address as USDC.e. Shared by the simulation and the real submit - * so both model the exact same calls. - * - * @param relayDepositAddress - The Relay deposit address that receives the - * unwrapped USDC.e. - * @param amount - The pUSD amount to unwrap. - * @returns The two `{ target, data }` calls. - */ -function buildDepositWalletUnwrapCalls( - relayDepositAddress: Hex, - amount: bigint, -): { target: Hex; data: Hex }[] { - return [ - { - target: POLYGON_PUSD_ADDRESS, - data: encodeApprove(POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, amount), - }, - { - target: POLYMARKET_COLLATERAL_OFFRAMP_POLYGON, - data: encodeUnwrap({ - asset: POLYGON_USDCE_ADDRESS, - recipient: relayDepositAddress, - amount, - }), - }, - ]; -} diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index 03487050b8e..93a32cf364b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -708,7 +708,7 @@ describe('Relay Quotes Utils', () => { expect(body.recipient).toBe(TOKEN_TRANSFER_RECIPIENT_MOCK.toLowerCase()); }); - it('extracts recipient and sets refundTo to sender when nested transactions include token transfer with delegation', async () => { + it('extracts recipient and sets refundTo when nested transactions include token transfer with delegation', async () => { successfulFetchMock.mockResolvedValue({ ok: true, json: async () => QUOTE_MOCK, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 01e684698ac..613d15ccc6b 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -2,6 +2,10 @@ import { Interface } from '@ethersproject/abi'; import { toHex } from '@metamask/controller-utils'; +import { + TransactionType, + hasTransactionType, +} from '@metamask/transaction-controller'; import type { AuthorizationList, TransactionMeta, @@ -57,11 +61,7 @@ import { } from '../../utils/token.js'; import { TOKEN_TRANSFER_FOUR_BYTE } from './constants.js'; import { applyHyperliquidActivationFee } from './hyperliquid-activation.js'; -import { - applyPolymarketDepositWalletOverrides, - getPredictWithdrawSafeAddress, - isPredictWithdraw, -} from './polymarket/withdraw.js'; +import { applyPolymarketDepositWalletOverrides } from './polymarket/withdraw.js'; import { fetchRelayQuote } from './relay-api.js'; import { getRelayMaxGasStationQuote } from './relay-max-gas-station.js'; import { validateRelayQuotes } from './relay-validation.js'; @@ -968,7 +968,9 @@ async function calculateSourceNetworkCost( const { chainId, data, maxFeePerGas, maxPriorityFeePerGas, to, value } = relayParams[0]; - const isPredictWithdrawFlow = isPredictWithdraw(request, transaction); + const isPredictWithdraw = + request.isPostQuote && + hasTransactionType(transaction, [TransactionType.predictWithdraw]); // `fromOverride = Safe proxy` is only valid for deposit-style Relay routes // where the deposit contract reads the user's source-token balance directly. @@ -978,11 +980,9 @@ async function calculateSourceNetworkCost( // native balance). Simulating those from the Safe proxy reverts and breaks // gas estimation. For swap-only routes, fall back to the relay params' // EOA `from` so simulation succeeds. - const fromOverride = getPredictWithdrawSafeAddress( - request, - quote.steps, - transaction, - ); + const hasDepositStep = quote.steps.some((step) => step.id === 'deposit'); + const useFromOverride = isPredictWithdraw && hasDepositStep; + const fromOverride = useFromOverride ? request.refundTo : undefined; // For post-quote flows the original transaction will be prepended to the // batch at submission time. Include it in the gas estimation so @@ -1078,7 +1078,7 @@ async function calculateSourceNetworkCost( // return nothing and force users to hold POL. // (`useFromOverride` only governs the gas-estimation `from` address, where // swap-style routes need EOA because DEX routers reject contract callers.) - if (isPredictWithdrawFlow && request.refundTo) { + if (isPredictWithdraw && request.refundTo) { log('Using proxy address for predict withdraw gas station simulation', { proxyAddress: request.refundTo, sourceTokenAddress, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts index 69477e7ed50..1c83ef2205e 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.test.ts @@ -1,7 +1,4 @@ -import { - generateEIP7702BatchTransaction, - TransactionType, -} from '@metamask/transaction-controller'; +import { generateEIP7702BatchTransaction } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; @@ -30,49 +27,10 @@ const { validateQuoteExecution } = jest.requireMock< >('../../utils/validation'); const FROM_MOCK = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; -const REFUND_TO_MOCK = '0x1111111111111111111111111111111111111111' as Hex; -const DEPOSIT_WALLET_MOCK = '0x2222222222222222222222222222222222222222' as Hex; const REQUEST_ID_MOCK = '0xreqid1234' as string; const CHAIN_ID_MOCK = '0x1' as Hex; const TOKEN_ADDRESS_MOCK = '0xtoken' as Hex; -// transfer(0x1234...7890, 1000000) encoded calldata; the recipient is decoded -// as the Relay deposit address for the deposit-wallet unwrap. -const TRANSFER_CALLDATA_MOCK = - '0xa9059cbb0000000000000000000000001234567890123456789012345678901234567890000000000000000000000000000000000000000000000000000000003b9aca00' as Hex; - -const DEPOSIT_STEP_MOCK = { - requestId: REQUEST_ID_MOCK, - id: 'deposit', - kind: 'transaction', - items: [], -}; - -const PREDICT_WITHDRAW_TRANSACTION_MOCK = { - id: 'tx-id', - txParams: { from: FROM_MOCK }, - type: TransactionType.predictWithdraw, -} as TransactionMeta; - -const PREDICT_WITHDRAW_TRANSACTION_WITH_NESTED_MOCK = { - id: 'tx-id', - txParams: { - from: FROM_MOCK, - to: '0xtoplevel' as Hex, - data: '0xtopleveldata' as Hex, - value: '0x0' as Hex, - }, - type: TransactionType.predictWithdraw, - nestedTransactions: [ - { to: '0xsafeapprove' as Hex, data: '0xsafeapprovedata' as Hex }, - { - to: '0xsafewithdraw' as Hex, - data: '0xsafewithdrawdata' as Hex, - value: '0x0' as Hex, - }, - ], -} as TransactionMeta; - function buildQuote( overrides: Partial['request']> = {}, originalOverrides: Partial = {}, @@ -136,11 +94,8 @@ const generateEIP7702BatchTransactionMock = jest.mocked( ); describe('validateRelayQuotes', () => { - const { - messenger, - getRemoteFeatureFlagControllerStateMock, - polymarketGetDepositWalletAddressMock, - } = getMessengerMock(); + const { messenger, getRemoteFeatureFlagControllerStateMock } = + getMessengerMock(); beforeEach(() => { jest.resetAllMocks(); @@ -149,12 +104,14 @@ describe('validateRelayQuotes', () => { ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay_extended: { - payStrategies: { relay: { validationEnabled: { default: true } } }, + payStrategies: { relay: { validationEnabled: true } }, }, }, }); - getRelaySubmitCallsMock.mockResolvedValue({ calls: [] }); + getRelaySubmitCallsMock.mockResolvedValue({ + calls: [], + }); getRelayExecuteRequestMock.mockResolvedValue(undefined as never); validateQuoteExecutionMock.mockResolvedValue(undefined); generateEIP7702BatchTransactionMock.mockReturnValue({ @@ -177,71 +134,13 @@ describe('validateRelayQuotes', () => { expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); }); - it('validates Polymarket deposit wallet quotes by simulating the real approve + unwrap batch', async () => { - polymarketGetDepositWalletAddressMock.mockResolvedValue( - DEPOSIT_WALLET_MOCK, - ); - - const quote = buildQuote( - { isPolymarketDepositWallet: true, isPostQuote: true }, - { - steps: [ - { - ...DEPOSIT_STEP_MOCK, - items: [{ data: { data: TRANSFER_CALLDATA_MOCK } }], - }, - ], - } as unknown as Partial, - ); + it('skips validation for Polymarket deposit wallet quotes', async () => { + const quote = buildQuote({ isPolymarketDepositWallet: true }); await validateRelayQuotes({ messenger, quotes: [quote], - transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, - }); - - // The Relay submit calldata is a placeholder for this variant and must not - // be used to build the simulation. - expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); - expect(polymarketGetDepositWalletAddressMock).toHaveBeenCalledWith({ - eoa: FROM_MOCK, - }); - - const { transactions } = - validateQuoteExecutionMock.mock.calls[0][0].simulation; - expect(transactions).toHaveLength(2); - expect(transactions[0].from).toBe(DEPOSIT_WALLET_MOCK); - expect(transactions[1].from).toBe(DEPOSIT_WALLET_MOCK); - }); - - it('skips validation entirely for a Safe-based (non-deposit-wallet) Predict withdraw', async () => { - const quote = buildQuote({ isPostQuote: true, refundTo: REFUND_TO_MOCK }, { - metamask: { gasLimits: [], is7702: false, isExecute: false }, - steps: [DEPOSIT_STEP_MOCK], - } as unknown as Partial); - - await validateRelayQuotes({ - messenger, - quotes: [quote], - transaction: PREDICT_WITHDRAW_TRANSACTION_WITH_NESTED_MOCK, - }); - - // Legacy Safe withdraws convert USDC.e to pUSD outside the calls the - // controller can see, so a faithful simulation is impossible. The quote is - // skipped: no submit calls are built and no simulation is validated. - expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); - expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); - }); - - it('skips validation for a swap-only Safe-based Predict withdraw (no deposit step)', async () => { - const quote = buildQuote({ isPostQuote: true, refundTo: REFUND_TO_MOCK }, { - metamask: { gasLimits: [], is7702: false, isExecute: false }, - } as Partial); - - await validateRelayQuotes({ - messenger, - quotes: [quote], - transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + transaction: TRANSACTION_MOCK, }); expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); @@ -587,9 +486,7 @@ describe('validateRelayQuotes', () => { ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay_extended: { - payStrategies: { - relay: { validationEnabled: { default: true } }, - }, + payStrategies: { relay: { validationEnabled: true } }, }, confirmations_eip_7702: { contracts: { @@ -742,41 +639,6 @@ describe('validateRelayQuotes', () => { .transactions[0]; expect(batchTx).not.toHaveProperty('gas'); }); - - it('skips validation for a deposit-style Safe Predict withdraw even when the quote is 7702', async () => { - const quote = buildQuote( - { isPostQuote: true, refundTo: REFUND_TO_MOCK }, - { - metamask: { gasLimits: [21000], is7702: true, isExecute: false }, - request: { - authorizationList: [ - { - address: '0xabc' as Hex, - chainId: 1, - nonce: 1, - r: '0xr' as Hex, - s: '0xs' as Hex, - yParity: 0, - }, - ], - }, - steps: [DEPOSIT_STEP_MOCK], - } as unknown as Partial, - ); - - await validateRelayQuotes({ - messenger, - quotes: [quote], - transaction: PREDICT_WITHDRAW_TRANSACTION_WITH_NESTED_MOCK, - }); - - // A Safe withdraw is a signed Safe `execTransaction` that converts USDC.e - // to pUSD outside the controller's visible calls, so it is skipped before - // any 7702 batch wrapper or simulation is built. - expect(generateEIP7702BatchTransactionMock).not.toHaveBeenCalled(); - expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); - expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); - }); }); describe('execute simulation (isExecute true)', () => { @@ -933,30 +795,6 @@ describe('validateRelayQuotes', () => { }), ); }); - - it('skips validation for a deposit-style Safe Predict withdraw even when the quote is execute', async () => { - const quote = buildQuote( - { isPostQuote: true, refundTo: REFUND_TO_MOCK }, - { - metamask: { gasLimits: [], is7702: false, isExecute: true }, - steps: [DEPOSIT_STEP_MOCK], - } as unknown as Partial, - ); - - await validateRelayQuotes({ - messenger, - quotes: [quote], - transaction: PREDICT_WITHDRAW_TRANSACTION_WITH_NESTED_MOCK, - }); - - // The real submit is a signed Safe `execTransaction`, not a - // `redeemDelegations` authorized for the EOA, and it converts USDC.e to - // pUSD outside the controller's visible calls. It is skipped before the - // execute request or any simulation is built. - expect(getRelayExecuteRequestMock).not.toHaveBeenCalled(); - expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); - expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); - }); }); }); @@ -974,59 +812,4 @@ describe('validateRelayQuotes', () => { expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); }); - - it('skips validation when per-type override is false', async () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: true, - transactionTypes: { simpleSend: false }, - }, - }, - }, - }, - }, - }); - - await validateRelayQuotes({ - messenger, - quotes: [buildQuote()], - transaction: { ...TRANSACTION_MOCK, type: 'simpleSend' as never }, - }); - - expect(getRelaySubmitCallsMock).not.toHaveBeenCalled(); - expect(validateQuoteExecutionMock).not.toHaveBeenCalled(); - }); - - it('runs validation when per-type override is true and default is false', async () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: false, - transactionTypes: { simpleSend: true }, - }, - }, - }, - }, - }, - }); - - getRelaySubmitCallsMock.mockResolvedValue({ calls: [] }); - - await validateRelayQuotes({ - messenger, - quotes: [buildQuote()], - transaction: { ...TRANSACTION_MOCK, type: 'simpleSend' as never }, - }); - - expect(validateQuoteExecutionMock).toHaveBeenCalled(); - }); }); diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts index f8e7c0099fd..dfecbbeda54 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-validation.ts @@ -23,10 +23,6 @@ import { isQuoteError, } from '../../utils/validation.js'; import type { QuoteSimulation } from '../../utils/validation.js'; -import { - buildPolymarketDepositWalletSimulation, - isPredictWithdraw, -} from './polymarket/withdraw.js'; import { getRelayExecuteRequest } from './relay-submit-execute.js'; import { getRelaySubmitCalls } from './relay-submit.js'; import type { RelayExecuteRequest, RelayQuote } from './types.js'; @@ -43,23 +39,42 @@ export type ValidateRelayQuotesRequest = { export async function validateRelayQuotes( request: ValidateRelayQuotesRequest, ): Promise { - if (!isRelayValidationEnabled(request.messenger, request.transaction)) { + if (!isRelayValidationEnabled(request.messenger)) { return; } for (const quote of request.quotes) { - if (shouldSkipValidation(quote, request.transaction)) { + if (shouldSkipValidation(quote)) { continue; } try { - const simulation = await buildValidationSimulation(request, quote); + const { calls } = await getRelaySubmitCalls({ + messenger: request.messenger, + quote, + transaction: request.transaction, + }); + + const executeRequest = quote.original.metamask.isExecute + ? await getRelayExecuteRequest({ + allParams: calls, + messenger: request.messenger, + quote, + requestId: quote.original.steps[0].requestId, + transaction: request.transaction, + }) + : undefined; await validateQuoteExecution({ messenger: request.messenger, quote, signal: request.signal, - simulation, + simulation: buildRelayValidationSimulation( + request.messenger, + quote, + calls, + executeRequest, + ), }); } catch (error) { if (request.signal?.aborted) { @@ -80,67 +95,10 @@ export async function validateRelayQuotes( } } -function shouldSkipValidation( - quote: TransactionPayQuote, - transaction: TransactionMeta, -): boolean { +function shouldSkipValidation(quote: TransactionPayQuote): boolean { const { request } = quote; - - if (request.isHyperliquidSource) { - log('Skipping quote validation: Hyperliquid source', { - from: request.from, - }); - return true; - } - - // Legacy Safe Predict withdraws convert USDC.e to pUSD outside the calls the - // controller can see, so they cannot be faithfully simulated and are skipped. - if ( - isPredictWithdraw(request, transaction) && - !request.isPolymarketDepositWallet - ) { - log('Skipping quote validation: legacy Safe Predict withdraw', { - from: request.from, - }); - return true; - } - - return false; -} - -async function buildValidationSimulation( - request: ValidateRelayQuotesRequest, - quote: TransactionPayQuote, -): Promise { - if (quote.request.isPolymarketDepositWallet) { - return await buildPolymarketDepositWalletSimulation( - quote, - quote.request.from, - request.messenger, - ); - } - - const { calls } = await getRelaySubmitCalls({ - messenger: request.messenger, - quote, - transaction: request.transaction, - }); - - const executeRequest = quote.original.metamask.isExecute - ? await getRelayExecuteRequest({ - allParams: calls, - messenger: request.messenger, - quote, - requestId: quote.original.steps[0].requestId, - transaction: request.transaction, - }) - : undefined; - - return buildRelayValidationSimulation( - request.messenger, - quote, - calls, - executeRequest, + return Boolean( + request.isHyperliquidSource ?? request.isPolymarketDepositWallet ?? false, ); } diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index f81bbf7516d..b9f63a01d41 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -70,6 +70,8 @@ export function getMessengerMock({ TransactionControllerAddTransactionBatchAction['handler'] > = jest.fn(); + const getMoneyAccountBalanceMock = jest.fn(); + const findNetworkClientIdByChainIdMock: jest.MockedFn< NetworkControllerFindNetworkClientIdByChainIdAction['handler'] > = jest.fn(); @@ -191,6 +193,11 @@ export function getMessengerMock({ addTransactionBatchMock, ); + messenger.registerActionHandler( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + getMoneyAccountBalanceMock, + ); + messenger.registerActionHandler( 'NetworkController:findNetworkClientIdByChainId', findNetworkClientIdByChainIdMock, @@ -320,6 +327,7 @@ export function getMessengerMock({ getGasFeeControllerStateMock, getGasFeeTokensMock, getKeyringControllerStateMock, + getMoneyAccountBalanceMock, getNetworkClientByIdMock, getNetworkConfigurationByChainIdMock, getRemoteFeatureFlagControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index b8ee97a3990..47adc7a8ba5 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -61,6 +61,15 @@ import type { } from './constants.js'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types.js'; +type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: 'MoneyAccountBalanceService:getMoneyAccountBalance'; + handler: (accountAddress: Hex) => Promise<{ + musdBalance: string; + totalBalance: string; + vmusdValueInMusd: string; + }>; +}; + export type AllowedActions = | AccountTrackerControllerGetStateAction | AssetsControllerGetStateForTransactionPayAction @@ -68,6 +77,7 @@ export type AllowedActions = | GetGasFeeState | KeyringControllerGetStateAction | KeyringControllerSignTypedMessageAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByChainIdAction @@ -824,6 +834,7 @@ export type PayStrategy = { /** Execute or submit the quotes to obtain required tokens. */ execute: (request: PayStrategyExecuteRequest) => Promise<{ + skipped?: true; transactionHash?: Hex; }>; }; diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts index acbffb5e2f8..af4639ce278 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.test.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts @@ -9,13 +9,19 @@ jest.mock('./provider'); const MONEY_ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const OTHER_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const CHOMP_TX_HASH = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; const FROM_BLOCK = '0x100' as Hex; const SOURCE_AMOUNT_RAW = '5000000'; // 5 mUSD (6 decimals) -// uint256 hex for 5000000 (>= source amount) -const TRANSFER_DATA_SUFFICIENT = +// uint256 hex for 5000000 (exact source amount) +const TRANSFER_DATA_EXACT = '0x00000000000000000000000000000000000000000000000000000000004c4b40'; +// uint256 hex for 5000001 (above source amount) +const TRANSFER_DATA_ABOVE = + '0x00000000000000000000000000000000000000000000000000000000004c4b41'; // uint256 hex for 4999999 (< source amount) const TRANSFER_DATA_INSUFFICIENT = '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; @@ -28,11 +34,17 @@ function padAddress(address: string): string { } const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS); - -function buildMusdTransferLog( - txHash: Hex = CHOMP_TX_HASH, - data: string = TRANSFER_DATA_SUFFICIENT, -): { +const BORING_VAULT_PADDED = padAddress(BORING_VAULT_ADDRESS); + +function buildMusdTransferLog({ + txHash = CHOMP_TX_HASH, + data = TRANSFER_DATA_EXACT, + to = BORING_VAULT_ADDRESS, +}: { + txHash?: Hex; + data?: string; + to?: Hex; +} = {}): { address: string; topics: string[]; data: string; @@ -41,11 +53,7 @@ function buildMusdTransferLog( return { address: MUSD_MONAD_ADDRESS, data, - topics: [ - ERC20_TRANSFER_TOPIC, - MONEY_ACCOUNT_PADDED, - padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), - ], + topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, padAddress(to)], transactionHash: txHash, }; } @@ -62,7 +70,7 @@ describe('chomp', () => { }); describe('findRecentChompVaultDeposit', () => { - it('returns the CHOMP tx hash when a Transfer log with sufficient amount is found', async () => { + it('returns the CHOMP tx hash when Transfer is to the vault with exact amount', async () => { rpcRequestMock.mockResolvedValueOnce([buildMusdTransferLog()]); const result = await findRecentChompVaultDeposit({ @@ -70,16 +78,50 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(CHOMP_TX_HASH); - // Only eth_getLogs should have been called. + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when Transfer is not to the vault', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ to: OTHER_RECIPIENT }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when the transfer amount does not exactly match', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ data: TRANSFER_DATA_ABOVE }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); it('returns undefined when the mUSD transfer amount is below the required amount', async () => { rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(CHOMP_TX_HASH, TRANSFER_DATA_INSUFFICIENT), + buildMusdTransferLog({ data: TRANSFER_DATA_INSUFFICIENT }), ]); const result = await findRecentChompVaultDeposit({ @@ -87,6 +129,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); @@ -101,13 +144,14 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('queries eth_getLogs with the correct filter', async () => { + it('queries eth_getLogs filtered to transfers from the Money Account to the vault', async () => { rpcRequestMock.mockResolvedValueOnce([]); await findRecentChompVaultDeposit({ @@ -115,6 +159,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(rpcRequestMock).toHaveBeenCalledWith( @@ -126,22 +171,26 @@ describe('chomp', () => { address: MUSD_MONAD_ADDRESS, fromBlock: FROM_BLOCK, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, null], + topics: [ + ERC20_TRANSFER_TOPIC, + MONEY_ACCOUNT_PADDED, + BORING_VAULT_PADDED, + ], }), ], }), ); }); - it('processes logs newest-first and returns the most recent match', async () => { + it('processes logs newest-first and returns the most recent exact vault match', async () => { const olderHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; const newerHash = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(olderHash), - buildMusdTransferLog(newerHash), + buildMusdTransferLog({ txHash: olderHash }), + buildMusdTransferLog({ txHash: newerHash }), ]); const result = await findRecentChompVaultDeposit({ @@ -149,19 +198,23 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(newerHash); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('skips logs with insufficient amount and returns the first sufficient one', async () => { - const insufficientHash = + it('skips amount mismatches and returns the first exact vault match', async () => { + const mismatchedHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(insufficientHash, TRANSFER_DATA_INSUFFICIENT), - buildMusdTransferLog(CHOMP_TX_HASH), + buildMusdTransferLog({ + txHash: mismatchedHash, + data: TRANSFER_DATA_INSUFFICIENT, + }), + buildMusdTransferLog(), ]); const result = await findRecentChompVaultDeposit({ @@ -169,16 +222,16 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); - // Logs reversed: CHOMP_TX_HASH checked first (newer), passes amount check. expect(result).toBe(CHOMP_TX_HASH); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); it('treats a log with data "0x" as zero amount and skips it', async () => { rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(CHOMP_TX_HASH, '0x'), + buildMusdTransferLog({ data: '0x' }), ]); const result = await findRecentChompVaultDeposit({ @@ -186,6 +239,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); diff --git a/packages/transaction-pay-controller/src/utils/chomp.ts b/packages/transaction-pay-controller/src/utils/chomp.ts index 2c2cc466276..073ea4b3492 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.ts @@ -19,18 +19,34 @@ type RpcLog = { transactionHash: Hex; }; +/** + * Finds a recent mUSD Transfer from the Money Account into the boring vault + * whose amount exactly matches `sourceAmountRaw`. Exact amount + vault `to` + * avoid treating Pix/other outbound transfers as CHOMP vault success. + * + * @param options - Scan options. + * @param options.messenger - Controller messenger for RPC. + * @param options.moneyAccountAddress - Money Account that sent the transfer. + * @param options.sourceAmountRaw - Exact raw mUSD amount expected. + * @param options.fromBlock - Inclusive block to start the log scan. + * @param options.vaultAddress - Boring vault address that must be the Transfer `to`. + * @returns Matching transaction hash, if any. + */ export async function findRecentChompVaultDeposit({ messenger, moneyAccountAddress, sourceAmountRaw, fromBlock, + vaultAddress, }: { messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; fromBlock: Hex; + vaultAddress: Hex; }): Promise { const fromPadded = padAddress(moneyAccountAddress); + const toPadded = padAddress(vaultAddress); const logs = await rpcRequest({ messenger, @@ -41,7 +57,7 @@ export async function findRecentChompVaultDeposit({ address: MUSD_MONAD_ADDRESS, fromBlock, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, fromPadded, null], + topics: [ERC20_TRANSFER_TOPIC, fromPadded, toPadded], }, ], }); @@ -50,16 +66,30 @@ export async function findRecentChompVaultDeposit({ count: logs.length, fromBlock, moneyAccountAddress, + vaultAddress, }); const requiredAmount = BigInt(sourceAmountRaw); + const vaultTopic = toPadded.toLowerCase(); // Examine newest logs first so we return the most recent CHOMP match. for (const txLog of [...logs].reverse()) { + const logTo = txLog.topics[2]?.toLowerCase(); + if (logTo !== vaultTopic) { + log('CHOMP scan: skipping log - transfer is not to the vault', { + expectedTo: vaultAddress, + logTo, + txHash: txLog.transactionHash, + }); + continue; + } + const transferAmount = BigInt(txLog.data === '0x' ? '0x0' : txLog.data); - if (transferAmount < requiredAmount) { - log('CHOMP scan: skipping log — transfer amount below required', { + // Exact amount only: >= would falsely treat larger outbound transfers + // (e.g. Pix) as vault deposits when `to` filtering alone is insufficient. + if (transferAmount !== requiredAmount) { + log('CHOMP scan: skipping log - transfer amount is not an exact match', { requiredAmount: requiredAmount.toString(), transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, @@ -72,12 +102,17 @@ export async function findRecentChompVaultDeposit({ sourceAmountRaw, transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, + vaultAddress, }); return txLog.transactionHash; } - log('CHOMP scan: no match found', { fromBlock, moneyAccountAddress }); + log('CHOMP scan: no match found', { + fromBlock, + moneyAccountAddress, + vaultAddress, + }); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts index 849bcba09b4..5ded719e29a 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.test.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.test.ts @@ -1,5 +1,4 @@ import { TransactionType } from '@metamask/transaction-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { getDefaultRemoteFeatureFlagControllerState } from '../../../remote-feature-flag-controller/src/remote-feature-flag-controller.js'; @@ -603,169 +602,29 @@ describe('Feature Flags Utils', () => { expect(isRelayValidationEnabled(messenger)).toBe(false); }); - it('returns true when default is true', () => { + it('returns true when validationEnabled is true', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay_extended: { - payStrategies: { - relay: { validationEnabled: { default: true } }, - }, + payStrategies: { relay: { validationEnabled: true } }, }, }, }); expect(isRelayValidationEnabled(messenger)).toBe(true); }); - it('returns false when default is false', () => { + it('returns false when validationEnabled is false', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { confirmations_pay_extended: { - payStrategies: { - relay: { validationEnabled: { default: false } }, - }, + payStrategies: { relay: { validationEnabled: false } }, }, }, }); expect(isRelayValidationEnabled(messenger)).toBe(false); }); - - it('returns false when default is omitted', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { validationEnabled: {} }, - }, - }, - }, - }); - expect(isRelayValidationEnabled(messenger)).toBe(false); - }); - - it('returns true when per-type override is true and default is false', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: false, - transactionTypes: { - [TransactionType.perpsDeposit]: true, - }, - }, - }, - }, - }, - }, - }); - expect( - isRelayValidationEnabled(messenger, { - type: TransactionType.perpsDeposit, - } as TransactionMeta), - ).toBe(true); - }); - - it('returns false when per-type override is false and default is true', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: true, - transactionTypes: { - [TransactionType.perpsDeposit]: false, - }, - }, - }, - }, - }, - }, - }); - expect( - isRelayValidationEnabled(messenger, { - type: TransactionType.perpsDeposit, - } as TransactionMeta), - ).toBe(false); - }); - - it('returns default value for a txType with no per-type override', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: true, - transactionTypes: { - [TransactionType.perpsDeposit]: false, - }, - }, - }, - }, - }, - }, - }); - expect( - isRelayValidationEnabled(messenger, { - type: TransactionType.simpleSend, - } as TransactionMeta), - ).toBe(true); - }); - - it('returns default value when no transaction is provided', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: true, - transactionTypes: { - [TransactionType.perpsDeposit]: false, - }, - }, - }, - }, - }, - }, - }); - expect(isRelayValidationEnabled(messenger)).toBe(true); - }); - - it('applies a per-type override matched via a nested transaction type', () => { - getRemoteFeatureFlagControllerStateMock.mockReturnValue({ - ...getDefaultRemoteFeatureFlagControllerState(), - remoteFeatureFlags: { - confirmations_pay_extended: { - payStrategies: { - relay: { - validationEnabled: { - default: false, - transactionTypes: { - [TransactionType.perpsDeposit]: true, - }, - }, - }, - }, - }, - }, - }); - expect( - isRelayValidationEnabled(messenger, { - type: TransactionType.simpleSend, - nestedTransactions: [{ type: TransactionType.perpsDeposit }], - } as TransactionMeta), - ).toBe(true); - }); }); describe('isChainExcludedFromInfura', () => { @@ -2208,7 +2067,7 @@ describe('Feature Flags Utils', () => { ); }); - it('returns flag value when stableTokens is a valid object', () => { + it('returns flag value when stable-tokens is a valid object', () => { const flagValue = { '0x1': ['0xaaa', '0xbbb'], '0xa4b1': ['0xccc'], @@ -2217,7 +2076,7 @@ describe('Feature Flags Utils', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { - stableTokens: flagValue, + 'stable-tokens': flagValue, }, }); @@ -2228,7 +2087,7 @@ describe('Feature Flags Utils', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { - stableTokens: { + 'stable-tokens': { '0xA4B1': ['0xAf88d065e77c8cC2239327C5EDb3A432268e5831'], }, }, @@ -2244,7 +2103,7 @@ describe('Feature Flags Utils', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { - stableTokens: { + 'stable-tokens': { '0x1': ['0xaaa'], '0xa4b1': 'not-an-array', }, @@ -2259,7 +2118,7 @@ describe('Feature Flags Utils', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { - stableTokens: ['not', 'an', 'object'], + 'stable-tokens': ['not', 'an', 'object'], }, }); @@ -2271,7 +2130,7 @@ describe('Feature Flags Utils', () => { getRemoteFeatureFlagControllerStateMock.mockReturnValue({ ...getDefaultRemoteFeatureFlagControllerState(), remoteFeatureFlags: { - stableTokens: true, + 'stable-tokens': true, }, }); diff --git a/packages/transaction-pay-controller/src/utils/feature-flags.ts b/packages/transaction-pay-controller/src/utils/feature-flags.ts index b9c34ebc06d..71a530dc464 100644 --- a/packages/transaction-pay-controller/src/utils/feature-flags.ts +++ b/packages/transaction-pay-controller/src/utils/feature-flags.ts @@ -1,8 +1,4 @@ -import { hasTransactionType } from '@metamask/transaction-controller'; -import type { - TransactionMeta, - TransactionType, -} from '@metamask/transaction-controller'; +import type { TransactionType } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { createModuleLogger } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; @@ -190,17 +186,12 @@ export type PayStrategiesConfigRaw = { }; }; -export type RelayValidationEnabledConfig = { - default?: boolean; - transactionTypes?: Partial>; -}; - type FeatureFlagsExtendedRaw = { excludeChainIdsFromInfura?: Hex[]; payStrategies?: { relay?: { gaslessEnabled?: boolean; - validationEnabled?: RelayValidationEnabledConfig; + validationEnabled?: boolean; }; server?: { enabled?: boolean; @@ -544,7 +535,7 @@ export function getFeatureFlags( } /** - * Get the stablecoins map from the `stableTokens` feature flag. + * Get the stablecoins map from the `stable-tokens` feature flag. * Falls back to the hardcoded {@link STABLECOINS} constant when the flag is * absent or not a valid object. * @@ -555,7 +546,7 @@ export function getStablecoins( messenger: TransactionPayControllerMessenger, ): Record { const state = messenger.call('RemoteFeatureFlagController:getState'); - const flag = state.remoteFeatureFlags?.stableTokens; + const flag = state.remoteFeatureFlags?.['stable-tokens']; if (flag && typeof flag === 'object' && !Array.isArray(flag)) { const raw = flag as Record; @@ -646,47 +637,23 @@ export function isRelayExecuteEnabled( } /** - * Whether Relay quote validation is enabled for a given transaction. + * Whether Relay quote validation is enabled. * * Acts as an emergency kill switch: when disabled (default), Relay quotes are * surfaced without being simulated/validated. * - * Configured via the `payStrategies.relay.validationEnabled` flag, an object - * `{ default?: boolean; transactionTypes?: { [type]?: boolean } }`: - * `default` is the base toggle applied to all transactions (omitted = `false`); - * a matching `transactionTypes[type]` entry overrides `default` when the - * transaction, or any of its nested transactions, has that type. - * * @param messenger - Controller messenger. - * @param transaction - Transaction being validated. Its top-level and nested - * types are matched against the `transactionTypes` overrides. * @returns True if Relay quote validation is enabled. */ export function isRelayValidationEnabled( messenger: TransactionPayControllerMessenger, - transaction?: TransactionMeta, ): boolean { const state = messenger.call('RemoteFeatureFlagController:getState'); const featureFlags = (state.remoteFeatureFlags?.confirmations_pay_extended as | FeatureFlagsExtendedRaw | undefined) ?? {}; - - const validationEnabled = - featureFlags.payStrategies?.relay?.validationEnabled; - - const transactionTypes = validationEnabled?.transactionTypes ?? {}; - - // A per-type override wins over the global `default` toggle. An override - // matches when the transaction, or any nested transaction, has that type. - for (const [type, enabled] of Object.entries(transactionTypes)) { - if (hasTransactionType(transaction, [type as TransactionType])) { - return enabled; - } - } - - // `?? false`: `default` is optional, so an omitted config disables validation. - return validationEnabled?.default ?? false; + return featureFlags.payStrategies?.relay?.validationEnabled ?? false; } /** diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 1aca3ef304c..d167538513e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -7,7 +7,11 @@ import type { Hex } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; -import { submitMoneyAccountVaultDeposit } from './ma-vault-deposit.js'; +import { + submitMoneyAccountVaultDeposit, + submitMoneyAccountVaultDepositBatch, +} from './ma-vault-deposit.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -17,12 +21,15 @@ import { } from './transaction.js'; jest.mock('./chomp'); +jest.mock('./money-account-vault-config'); jest.mock('./provider'); jest.mock('./transaction'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS_MOCK = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; const TRANSACTION_MOCK = { @@ -72,6 +79,9 @@ function callSubmit({ describe('submitMoneyAccountVaultDeposit', () => { const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); @@ -82,6 +92,13 @@ describe('submitMoneyAccountVaultDeposit', () => { beforeEach(() => { jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue({ + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: BORING_VAULT_ADDRESS_MOCK, + chainId: '0x8f' as Hex, + lensAddress: '0x3333333333333333333333333333333333333333' as Hex, + tellerAddress: '0x4444444444444444444444444444444444444444' as Hex, + }); getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); collectTransactionIdsMock.mockImplementation( (_chainId, _from, _messenger, onTransaction) => { @@ -270,7 +287,7 @@ describe('submitMoneyAccountVaultDeposit', () => { const result = await callSubmit({ callMock, vaultDisabled: true }); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalled(); expect(updateTransactionMock).not.toHaveBeenCalled(); expect(collectTransactionIdsMock).not.toHaveBeenCalled(); @@ -512,4 +529,46 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); }); }); + + describe('parentless vault batches', () => { + const depositCalls: BatchTransactionParams[] = [ + { data: '0xapprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xdeposit' as Hex, to: '0xdeposit' as Hex }, + ]; + + it('submits without updating a parent transaction', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + + it('returns before submission when disabled', async () => { + const callMock = jest.fn(); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: true, + }); + + expect(callMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ skipped: true }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 8f3facf82f5..7f46689106e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -13,6 +13,7 @@ import { MUSD_MONAD_FIAT_ASSET } from '../strategy/fiat/constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; import { prefixError } from './error-prefix.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -25,6 +26,11 @@ const log = createModuleLogger(projectLogger, 'ma-vault-deposit'); export const VAULT_ERROR_PREFIX = 'Vault: '; +export type SubmitMoneyAccountVaultDepositResult = { + skipped?: true; + transactionHash?: Hex; +}; + /** * Submits a Money Account mUSD vault deposit batch on Monad once the source * mUSD has settled in the Money Account (fiat on-ramp, Relay bridge, or any @@ -47,7 +53,8 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave * the settled mUSD in the Money Account. Caller-evaluated kill-switch. - * @returns Hash of the final submitted child transaction, if available. + * @returns Hash of the final submitted child transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDeposit({ fromBlock, @@ -65,7 +72,7 @@ export async function submitMoneyAccountVaultDeposit({ sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const transactionId = transaction.id; const moneyAccountAddress = (moneyAccountAddressOverride ?? transaction.txParams.from) as Hex | undefined; @@ -81,7 +88,7 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } const nestedTransactions = await resolveVaultDepositBatch({ @@ -92,6 +99,60 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: nestedTransactions, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled: false, + }); +} + +/** + * Submits pre-built Money Account vault calls without requiring a parent + * transaction. When `transactionId` is supplied, submitted child IDs are also + * linked to that parent for the existing Fiat and Relay flows. + * + * @param options - Submission options. + * @param options.depositCalls - Pre-built approve and deposit calls. + * @param options.fromBlock - Block at which to begin the CHOMP race check. + * @param options.messenger - Transaction Pay controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Raw mUSD amount to deposit. + * @param options.transactionId - Optional parent transaction to link children. + * @param options.vaultDisabled - Whether vault submission is disabled. + * @returns Hash of the final confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. + */ +export async function submitMoneyAccountVaultDepositBatch({ + depositCalls, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled, +}: { + depositCalls: NestedTransactionMetadata[]; + fromBlock?: Hex; + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId?: string; + vaultDisabled: boolean; +}): Promise { + if (vaultDisabled) { + log('Skipping vault deposit because vaultDisabled is true', { + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { skipped: true }; + } + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. const preChompHash = await tryFindChompDeposit({ @@ -117,23 +178,25 @@ export async function submitMoneyAccountVaultDeposit({ messenger, (id) => { transactionIds.push(id); - updateTransaction( - { - transactionId, - messenger, - note: 'Add required transaction ID from Money Account vault submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(id); - }, - ); + if (transactionId) { + updateTransaction( + { + transactionId, + messenger, + note: 'Add required transaction ID from Money Account vault submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + } }, ); log('Submitting Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -151,7 +214,7 @@ export async function submitMoneyAccountVaultDeposit({ origin: ORIGIN_METAMASK, requireApproval: false, skipInitialGasEstimate: true, - transactions: nestedTransactions.map((nestedTransaction, index) => ({ + transactions: depositCalls.map((nestedTransaction, index) => ({ params: { data: nestedTransaction.data, to: nestedTransaction.to, @@ -185,7 +248,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Submitted Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -209,7 +272,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Confirmed Money Account vault deposit', { hash, moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -316,18 +379,20 @@ async function tryFindChompDeposit({ messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; - transactionId: string; + transactionId?: string; }): Promise { if (!fromBlock) { return undefined; } try { + const { boringVault } = getMoneyAccountVaultConfig(messenger); return await findRecentChompVaultDeposit({ fromBlock, messenger, moneyAccountAddress, sourceAmountRaw, + vaultAddress: boringVault, }); } catch (chompError) { log('CHOMP check failed', { chompError, transactionId }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts new file mode 100644 index 00000000000..ca8098195ef --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -0,0 +1,198 @@ +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './ma-vault-payout.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./ma-vault-deposit'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); +jest.mock('./transaction'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const PAYOUT_HASH = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const VAULT_HASH = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: '0x3333333333333333333333333333333333333333' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444' as Hex, + tellerAddress: '0x5555555555555555555555555555555555555555' as Hex, +}; + +function getMessenger(): TransactionPayControllerMessenger { + return { + call: jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + throw new Error(`Unexpected action: ${action}`); + }), + } as unknown as TransactionPayControllerMessenger; +} + +describe('submitMoneyAccountVaultDepositFromPayout', () => { + const buildMoneyAccountDepositBatchMock = jest.mocked( + buildMoneyAccountDepositBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); + const submitMoneyAccountVaultDepositBatchMock = jest.mocked( + submitMoneyAccountVaultDepositBatch, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: '5000000', + blockNumber: '0x123', + }); + buildMoneyAccountDepositBatchMock.mockResolvedValue({ + approveTx: { + params: { + data: '0xapprove', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + }, + depositTx: { + params: { + data: '0xdeposit', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + }, + } as never); + submitMoneyAccountVaultDepositBatchMock.mockResolvedValue({ + transactionHash: VAULT_HASH, + }); + }); + + it('resolves the Iron payout and submits a parentless vault batch', async () => { + const messenger = getMessenger(); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: PAYOUT_HASH, + walletAddress: MONEY_ACCOUNT_ADDRESS, + }); + expect(buildMoneyAccountDepositBatchMock).toHaveBeenCalledWith({ + amount: 5000000n, + provider: expect.anything(), + ...VAULT_CONFIG, + }); + expect(submitMoneyAccountVaultDepositBatchMock).toHaveBeenCalledWith({ + depositCalls: [ + expect.objectContaining({ data: '0xapprove' }), + expect.objectContaining({ data: '0xdeposit' }), + ], + fromBlock: '0x123', + messenger, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + expect(result).toStrictEqual({ transactionHash: VAULT_HASH }); + }); + + it('defaults vaultDisabled to false', async () => { + const messenger = getMessenger(); + + await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a payout without an mUSD transfer to the Money Account', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: '0x123', + }); + + await expect( + submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ), + ).rejects.toThrow('Payout transaction has no mUSD transfer'); + + expect(buildMoneyAccountDepositBatchMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositBatchMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when vaulting is disabled', async () => { + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: true, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ skipped: true }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when deposits are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ skipped: true }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts new file mode 100644 index 00000000000..1c55b0948d2 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -0,0 +1,83 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +export type SubmitMoneyAccountVaultDepositRequest = { + moneyAccountAddress: Hex; + transactionHash: Hex; + vaultDisabled?: boolean; +}; + +/** + * Resolves an Iron payout transaction and vaults the received mUSD. + * + * @param request - Iron payout details. + * @param messenger - Transaction Pay controller messenger. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` when + * vaulting is disabled. + */ +export async function submitMoneyAccountVaultDepositFromPayout( + request: SubmitMoneyAccountVaultDepositRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + const { + moneyAccountAddress, + transactionHash, + vaultDisabled = false, + } = request; + + if ( + vaultDisabled || + !isMoneyAccountVaultActionEnabled(messenger, 'deposit') + ) { + return { skipped: true }; + } + + const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: transactionHash, + walletAddress: moneyAccountAddress, + }); + + if (!amountRaw || BigInt(amountRaw) <= 0n) { + throw new Error('Payout transaction has no mUSD transfer'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(amountRaw), + provider, + ...vaultConfig, + }); + + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: [ + { ...approveTx.params, type: approveTx.type }, + { ...depositTx.params, type: depositTx.type }, + ], + fromBlock: blockNumber, + messenger, + moneyAccountAddress, + sourceAmountRaw: amountRaw, + vaultDisabled: false, + }); +} diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts new file mode 100644 index 00000000000..fb732dcc2eb --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -0,0 +1,195 @@ +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw } from './ma-vault-withdraw.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const IRON_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x3333333333333333333333333333333333333333' as Hex, + boringVault: '0x4444444444444444444444444444444444444444' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x5555555555555555555555555555555555555555' as Hex, + tellerAddress: '0x6666666666666666666666666666666666666666' as Hex, +}; + +function getRequest( + overrides: Partial = {}, +): SubmitMoneyAccountVaultWithdrawRequest { + return { + amountInRaw: '5000000', + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + recipient: IRON_ADDRESS, + requestId: 'request-id', + ...overrides, + }; +} + +function getMessenger({ + balance = '5000000', +}: { + balance?: string; +} = {}): { + callMock: jest.Mock; + messenger: TransactionPayControllerMessenger; +} { + const callMock = jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + if (action === 'MoneyAccountBalanceService:getMoneyAccountBalance') { + return Promise.resolve({ + musdBalance: '0', + totalBalance: balance, + vmusdValueInMusd: balance, + }); + } + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: '0xbatch' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + messenger: { + call: callMock, + } as unknown as TransactionPayControllerMessenger, + }; +} + +describe('submitMoneyAccountVaultWithdraw', () => { + const buildMoneyAccountWithdrawBatchMock = jest.mocked( + buildMoneyAccountWithdrawBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + buildMoneyAccountWithdrawBatchMock.mockResolvedValue({ + transferTx: { + params: { + data: '0xtransfer', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + type: 'tokenMethodTransfer', + }, + withdrawTx: { + params: { + data: '0xwithdraw', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + type: 'moneyAccountWithdraw', + }, + } as never); + }); + + it('creates one user-confirmed atomic batch to the Iron address', async () => { + const { callMock, messenger } = getMessenger(); + const request = getRequest(); + + const result = await submitMoneyAccountVaultWithdraw(request, messenger); + + expect(buildMoneyAccountWithdrawBatchMock).toHaveBeenCalledWith({ + accountantAddress: VAULT_CONFIG.accountantAddress, + amount: 5000000n, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + provider: expect.anything(), + recipient: IRON_ADDRESS, + tellerAddress: VAULT_CONFIG.tellerAddress, + }); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: MONEY_ACCOUNT_ADDRESS, + isGasFeeSponsored: true, + isInternal: true, + networkClientId: NETWORK_CLIENT_ID, + origin: 'metamask', + requestId: 'request-id', + requireApproval: true, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ data: '0xwithdraw' }), + }), + expect.objectContaining({ + params: expect.objectContaining({ data: '0xtransfer' }), + }), + ], + }), + ); + expect(result).toStrictEqual({ batchId: '0xbatch' }); + }); + + it('rejects an amount above the withdrawable vmUSD value', async () => { + const { messenger } = getMessenger({ balance: '4999999' }); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), messenger), + ).rejects.toThrow('Insufficient withdrawable vmUSD balance'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it('rejects when Money Account withdrawals are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), getMessenger().messenger), + ).rejects.toThrow('Money Account vault withdrawal is disabled'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], + [{ recipient: '0x1234' }, 'Iron recipient is invalid'], + [ + { recipient: MONEY_ACCOUNT_ADDRESS }, + 'Iron recipient must differ from the Money Account', + ], + [{ requestId: '' }, 'Missing withdraw request id'], + ])('rejects invalid withdraw input %#', async (overrides, message) => { + await expect( + submitMoneyAccountVaultWithdraw( + getRequest(overrides), + getMessenger().messenger, + ), + ).rejects.toThrow(message); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts new file mode 100644 index 00000000000..7beb1bb9f6f --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -0,0 +1,115 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { TransactionBatchResult } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +/** + * On-chain withdraw intent. Quote / Pix / Iron identifiers stay outside Core; + * Monad and mUSD are fixed by the Money Account vault config constants. + */ +export type SubmitMoneyAccountVaultWithdrawRequest = { + amountInRaw: string; + moneyAccountAddress: Hex; + recipient: Hex; + requestId: string; +}; + +/** + * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. + * + * @param request - Exact-out withdraw intent. + * @param messenger - Transaction Pay controller messenger. + * @returns The pending transaction batch ID. + */ +export async function submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + validateRequest(request); + + if (!isMoneyAccountVaultActionEnabled(messenger, 'withdraw')) { + throw new Error('Money Account vault withdrawal is disabled'); + } + + const amount = BigInt(request.amountInRaw); + const balance = await messenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + request.moneyAccountAddress, + ); + + if (amount > BigInt(balance.vmusdValueInMusd)) { + throw new Error('Insufficient withdrawable vmUSD balance'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { withdrawTx, transferTx } = await buildMoneyAccountWithdrawBatch({ + accountantAddress: vaultConfig.accountantAddress, + amount, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: request.moneyAccountAddress, + provider, + recipient: request.recipient, + tellerAddress: vaultConfig.tellerAddress, + }); + + return await messenger.call('TransactionController:addTransactionBatch', { + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: request.moneyAccountAddress, + isGasFeeSponsored: true, + isInternal: true, + networkClientId, + origin: ORIGIN_METAMASK, + requestId: request.requestId, + requireApproval: true, + skipInitialGasEstimate: true, + transactions: [withdrawTx, transferTx], + }); +} + +function validateRequest( + request: SubmitMoneyAccountVaultWithdrawRequest, +): void { + if (!request.requestId) { + throw new Error('Missing withdraw request id'); + } + + let amount: bigint; + try { + amount = BigInt(request.amountInRaw); + } catch { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (amount <= 0n) { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (!isValidHexAddress(request.recipient)) { + throw new Error('Iron recipient is invalid'); + } + if ( + request.recipient.toLowerCase() === + request.moneyAccountAddress.toLowerCase() + ) { + throw new Error('Iron recipient must differ from the Money Account'); + } +} diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts new file mode 100644 index 00000000000..82ff8e0a9ca --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts @@ -0,0 +1,90 @@ +import type { Hex, Json } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; + +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222', + boringVault: '0x3333333333333333333333333333333333333333', + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444', + tellerAddress: '0x5555555555555555555555555555555555555555', +}; + +function getMessenger( + flag: unknown, + moneyAccount: unknown = undefined, +): TransactionPayControllerMessenger { + return { + call: jest.fn(() => ({ + remoteFeatureFlags: { + moneyAccount: moneyAccount as Json, + moneyAccountVaultConfig: flag as Json, + }, + })), + } as unknown as TransactionPayControllerMessenger; +} + +describe('getMoneyAccountVaultConfig', () => { + it('returns a valid Monad vault config', () => { + expect( + getMoneyAccountVaultConfig(getMessenger(VAULT_CONFIG)), + ).toStrictEqual(VAULT_CONFIG as Record); + }); + + it.each([ + ['deposit', { moneyAccountDepositEnabled: true }], + ['withdraw', { moneyAccountWithdrawEnabled: true }], + ] as const)('returns true when %s is enabled', (action, flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + action, + ), + ).toBe(true); + }); + + it.each(['deposit', 'withdraw'] as const)( + 'defaults %s to disabled', + (action) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, {}), + action, + ), + ).toBe(false); + }, + ); + + it.each([undefined, [], 'enabled'])( + 'treats non-object Money Account flags as disabled', + (flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + 'deposit', + ), + ).toBe(false); + }, + ); + + it('throws when vault config is missing', () => { + expect(() => getMoneyAccountVaultConfig(getMessenger(undefined))).toThrow( + 'Money Account vault config is unavailable', + ); + }); + + it.each([ + { ...VAULT_CONFIG, chainId: '0x1' }, + { ...VAULT_CONFIG, tellerAddress: '0x1234' }, + { ...VAULT_CONFIG, lensAddress: undefined }, + ])('throws when vault config is invalid', (config) => { + expect(() => getMoneyAccountVaultConfig(getMessenger(config))).toThrow( + 'Money Account vault config is invalid', + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts new file mode 100644 index 00000000000..5785eb2cd59 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts @@ -0,0 +1,86 @@ +import type { Hex, Json } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; + +const VAULT_CONFIG_FLAG = 'moneyAccountVaultConfig'; +const REQUIRED_ADDRESS_KEYS = [ + 'boringVault', + 'tellerAddress', + 'accountantAddress', + 'lensAddress', +] as const; + +type MoneyAccountVaultAction = 'deposit' | 'withdraw'; + +export type MoneyAccountVaultConfig = { + accountantAddress: Hex; + boringVault: Hex; + chainId: Hex; + lensAddress: Hex; + tellerAddress: Hex; +}; + +/** + * Reads and validates the Money Account vault configuration. + * + * @param messenger - Transaction Pay controller messenger. + * @returns Validated Monad vault configuration. + */ +export function getMoneyAccountVaultConfig( + messenger: TransactionPayControllerMessenger, +): MoneyAccountVaultConfig { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.[VAULT_CONFIG_FLAG]; + + if (value === undefined) { + throw new Error('Money Account vault config is unavailable'); + } + + if (!isVaultConfig(value)) { + throw new Error('Money Account vault config is invalid'); + } + + return value; +} + +/** + * Returns whether the requested Money Account vault action is enabled. + * + * @param messenger - Transaction Pay controller messenger. + * @param action - Vault action to inspect. + * @returns Whether the remote feature flag explicitly enables the action. + */ +export function isMoneyAccountVaultActionEnabled( + messenger: TransactionPayControllerMessenger, + action: MoneyAccountVaultAction, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.moneyAccount; + if (!value || Array.isArray(value) || typeof value !== 'object') { + return false; + } + + const key = + action === 'deposit' + ? 'moneyAccountDepositEnabled' + : 'moneyAccountWithdrawEnabled'; + return value[key] === true; +} + +function isVaultConfig(value: Json): value is Json & MoneyAccountVaultConfig { + if ( + !value || + Array.isArray(value) || + typeof value !== 'object' || + value.chainId !== CHAIN_ID_MONAD + ) { + return false; + } + + return REQUIRED_ADDRESS_KEYS.every((key) => { + const address = value[key]; + return typeof address === 'string' && isValidHexAddress(address as Hex); + }); +} diff --git a/packages/transaction-pay-controller/src/utils/validation.test.ts b/packages/transaction-pay-controller/src/utils/validation.test.ts index 2c46a7c3322..cb36ce347d7 100644 --- a/packages/transaction-pay-controller/src/utils/validation.test.ts +++ b/packages/transaction-pay-controller/src/utils/validation.test.ts @@ -107,33 +107,6 @@ describe('validateQuoteExecution', () => { ).toBeUndefined(); }); - it('reads the source balance at the quote from address', async () => { - const overrideAddress = - '0x1111111111111111111111111111111111111111' as Hex; - getLiveTokenBalanceMock.mockResolvedValue('500'); - - await validateQuoteExecution({ - messenger: messengerMock.messenger, - quote: buildQuote({}, '500'), - simulation: buildSimulation({ - transactions: [ - { - data: TRANSFER_DATA_MOCK as Hex, - from: overrideAddress, - to: TOKEN_ADDRESS_MOCK, - }, - ], - }), - }); - - expect(getLiveTokenBalanceMock).toHaveBeenCalledWith( - expect.anything(), - FROM_MOCK, - CHAIN_ID_MOCK, - TOKEN_ADDRESS_MOCK, - ); - }); - it('passes when live balance exceeds required amount', async () => { getLiveTokenBalanceMock.mockResolvedValue('9999'); @@ -336,109 +309,6 @@ describe('validateQuoteExecution', () => { }), ).toBeUndefined(); }); - - it('skips the check when the first transaction is not a source-token transfer, even if a later transfer exceeds the starting balance', async () => { - // The Safe starts with 0 source token; the first call produces it (e.g. - // unwrapping legacy USDC) before a later transfer spends it. The starting - // balance must not be treated as the constraint. - getLiveTokenBalanceMock.mockResolvedValue('0'); - - expect( - await validateQuoteExecution({ - messenger: messengerMock.messenger, - // Post-quote (like a Safe withdraw) so the up-front required-amount - // check is skipped and the decoded-transfer check is what matters. - quote: buildQuote({ isPostQuote: true }, '50'), - simulation: buildSimulation({ - transactions: [ - { - // Unwrap on some other contract, not a source-token transfer. - data: '0xdeadbeef' as Hex, - from: FROM_MOCK, - to: '0x9999999999999999999999999999999999999999' as Hex, - }, - { - // Transfer of 500 source token that only exists post-unwrap. - data: TRANSFER_DATA_MOCK as Hex, - from: FROM_MOCK, - to: TOKEN_ADDRESS_MOCK, - }, - ], - }), - }), - ).toBeUndefined(); - }); - - it('skips the check when there is more than one transaction, even if the first is a source-token transfer that exceeds the balance', async () => { - // A multi-step batch (e.g. Safe withdraw: transfer + a follow-up call) - // produces or transforms the source-token balance mid-batch, so the - // single-transfer balance heuristic does not apply. - getLiveTokenBalanceMock.mockResolvedValue('0'); - - expect( - await validateQuoteExecution({ - messenger: messengerMock.messenger, - quote: buildQuote({ isPostQuote: true }, '50'), - simulation: buildSimulation({ - transactions: [ - { - data: TRANSFER_DATA_MOCK as Hex, - from: FROM_MOCK, - to: TOKEN_ADDRESS_MOCK, - }, - { - data: '0xdeadbeef' as Hex, - from: FROM_MOCK, - to: '0x9999999999999999999999999999999999999999' as Hex, - }, - ], - }), - }), - ).toBeUndefined(); - }); - - it('skips the check for a single transfer that targets a non-source token', async () => { - getLiveTokenBalanceMock.mockResolvedValue('0'); - - expect( - await validateQuoteExecution({ - messenger: messengerMock.messenger, - quote: buildQuote({ isPostQuote: true }, '50'), - simulation: buildSimulation({ - transactions: [ - { - data: TRANSFER_DATA_MOCK as Hex, - from: FROM_MOCK, - // Transfer on a different token, not the quote's source token. - to: '0x9999999999999999999999999999999999999999' as Hex, - }, - ], - }), - }), - ).toBeUndefined(); - }); - - it('runs the check for a single source-token transfer that exceeds the live balance', async () => { - getLiveTokenBalanceMock.mockResolvedValue('100'); - - await expect( - validateQuoteExecution({ - messenger: messengerMock.messenger, - quote: buildQuote({}, '50'), - simulation: buildSimulation({ - transactions: [ - { - data: TRANSFER_DATA_MOCK as Hex, - from: FROM_MOCK, - to: TOKEN_ADDRESS_MOCK, - }, - ], - }), - }), - ).rejects.toMatchObject({ - info: { reason: 'insufficient-transfer-balance' }, - }); - }); }); describe('simulation', () => { diff --git a/packages/transaction-pay-controller/src/utils/validation.ts b/packages/transaction-pay-controller/src/utils/validation.ts index c34337a2365..d21aa65a4e0 100644 --- a/packages/transaction-pay-controller/src/utils/validation.ts +++ b/packages/transaction-pay-controller/src/utils/validation.ts @@ -78,10 +78,12 @@ export async function validateQuoteExecution({ validateRequiredSourceAmount(messenger, quote, liveBalance); + log('Quote source amount check passed'); + log('Checking decoded source transfers', { sourceChainId: quote.request.sourceChainId, sourceTokenAddress: quote.request.sourceTokenAddress, - transactions: simulation.transactions, + transactionCount: simulation.transactions.length, }); validateDecodedSourceTransfers( @@ -91,17 +93,10 @@ export async function validateQuoteExecution({ simulation.transactions, ); - throwIfAborted(signal); + log('Decoded source transfers check passed'); - await validateSimulation(messenger, quote, simulation, signal); -} + throwIfAborted(signal); -async function validateSimulation( - messenger: TransactionPayControllerMessenger, - quote: TransactionPayQuote, - simulation: QuoteSimulation, - signal?: AbortSignal, -): Promise { log('Starting simulation', { chainId: quote.request.sourceChainId, transactions: simulation.transactions, @@ -223,10 +218,6 @@ function validateRequiredSourceAmount( liveBalance: string, ): void { if (quote.request.isPostQuote || quote.request.paymentOverride) { - log('Skipping quote source amount check', { - hasPaymentOverride: Boolean(quote.request.paymentOverride), - isPostQuote: Boolean(quote.request.isPostQuote), - }); return; } @@ -234,7 +225,6 @@ function validateRequiredSourceAmount( const balance = new BigNumber(liveBalance); if (balance.isGreaterThanOrEqualTo(requiredAmount)) { - log('Quote source amount check passed'); return; } @@ -256,34 +246,21 @@ function validateDecodedSourceTransfers( liveBalance: string, transactions: SimulationTransaction[], ): void { - // Only valid for a single source-token transfer. Multi-step batches produce - // or transform the source-token balance mid-batch, so comparing against the - // starting balance is wrong; rely on the full simulation instead. - if ( - transactions.length !== 1 || - !isSourceTokenTransfer(quote, transactions[0]) - ) { - log( - 'Skipping decoded source transfer check: not a single source-token transfer', - ); - return; - } + const decodedAmounts = getDecodedSourceTransferAmounts(quote, transactions); - // `isSourceTokenTransfer` has already confirmed the data decodes to a - // transfer, so the amount is defined here. - const requiredAmount = decodeTransferAmount( - transactions[0].data as Hex, - ) as string; + const requiredAmount = decodedAmounts + .reduce((total, amount) => total.plus(amount), new BigNumber(0)) + .toString(10); const balance = new BigNumber(liveBalance); - log('Decoded source transfer amount', { + log('Decoded source transfer amounts', { + decodedAmounts, liveBalance, requiredAmount, }); if (balance.isGreaterThanOrEqualTo(requiredAmount)) { - log('Decoded source transfers check passed'); return; } @@ -299,22 +276,17 @@ function validateDecodedSourceTransfers( }); } -function isSourceTokenTransfer( +function getDecodedSourceTransferAmounts( quote: TransactionPayQuote, - transaction: SimulationTransaction | undefined, -): boolean { - if (!transaction?.to || !transaction.data) { - return false; - } - + transactions: SimulationTransaction[], +): string[] { const { sourceChainId, sourceTokenAddress } = quote.request; - const isNativeSource = sourceTokenAddress.toLowerCase() === getNativeToken(sourceChainId).toLowerCase(); if (isNativeSource) { - return false; + return []; } const normalizedSourceTokenAddress = normalizeTokenAddress( @@ -323,17 +295,20 @@ function isSourceTokenTransfer( TokenAddressTarget.MetaMask, ).toLowerCase(); - const normalizedTo = normalizeTokenAddress( - transaction.to, - sourceChainId, - TokenAddressTarget.MetaMask, - ).toLowerCase(); - - if (normalizedTo !== normalizedSourceTokenAddress) { - return false; - } - - return decodeTransferAmount(transaction.data) !== undefined; + return transactions + .filter( + (transaction) => + transaction.to && + normalizeTokenAddress( + transaction.to, + sourceChainId, + TokenAddressTarget.MetaMask, + ).toLowerCase() === normalizedSourceTokenAddress, + ) + .map((transaction) => + transaction.data ? decodeTransferAmount(transaction.data) : undefined, + ) + .filter((amount): amount is string => amount !== undefined); } function decodeTransferAmount(data: Hex): string | undefined { diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json index 4865a1c8327..ad329d91746 100644 --- a/packages/transaction-pay-controller/tsconfig.build.json +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -39,6 +39,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-utils/tsconfig.build.json" + }, { "path": "../sentinel-api-service/tsconfig.build.json" } diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json index 67ae32f3465..fbae571a6cc 100644 --- a/packages/transaction-pay-controller/tsconfig.json +++ b/packages/transaction-pay-controller/tsconfig.json @@ -37,6 +37,9 @@ { "path": "../messenger" }, + { + "path": "../money-account-utils" + }, { "path": "../sentinel-api-service" } diff --git a/packages/user-operation-controller/jest.config.js b/packages/user-operation-controller/jest.config.js index 53362a9b4f2..1022550c95a 100644 --- a/packages/user-operation-controller/jest.config.js +++ b/packages/user-operation-controller/jest.config.js @@ -17,10 +17,10 @@ module.exports = merge(baseConfig, { // An object that configures minimum threshold enforcement for coverage results coverageThreshold: { global: { - branches: 97.64, + branches: 99.18, functions: 100, - lines: 99.63, - statements: 99.63, + lines: 100, + statements: 100, }, }, }); diff --git a/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts b/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts index 4dd74f38429..f887a7e4050 100644 --- a/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts +++ b/packages/user-operation-controller/src/helpers/PendingUserOperationTracker.ts @@ -5,13 +5,13 @@ import type { NetworkClientId, Provider, } from '@metamask/network-controller'; +import { BlockTrackerPollingControllerOnly } from '@metamask/polling-controller'; import { createModuleLogger } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; // This package purposefully relies on Node's EventEmitter module. // eslint-disable-next-line import-x/no-nodejs-modules import EventEmitter from 'events'; -import { BlockTrackerPollingControllerOnly } from '../BlockTrackerPollingController.js'; import { projectLogger } from '../logger.js'; import type { UserOperationMetadata, UserOperationReceipt } from '../types.js'; import { UserOperationStatus } from '../types.js'; diff --git a/packages/wallet-cli/CHANGELOG.md b/packages/wallet-cli/CHANGELOG.md index a837ba09596..f8de389522c 100644 --- a/packages/wallet-cli/CHANGELOG.md +++ b/packages/wallet-cli/CHANGELOG.md @@ -31,7 +31,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `--password` / `MM_WALLET_PASSWORD` is now optional on `mm daemon start`; on subsequent runs, omitting it starts the daemon with a locked keyring, and the persisted vault is auto-unlocked when a password is supplied ([#8821](https://github.com/MetaMask/core/pull/8821)) - The daemon RPC server now validates `params` against each handler's superstruct before dispatch, returning a `-32602 invalidParams` error on mismatch instead of passing raw params to the handler ([#8846](https://github.com/MetaMask/core/pull/8846)) - Report daemon socket connection errors consistently across `mm daemon call` and `mm daemon list` ([#9339](https://github.com/MetaMask/core/pull/9339)) -- Bump `@metamask/wallet` from `^3.0.0` to `^11.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9609](https://github.com/MetaMask/core/pull/9609), [#9629](https://github.com/MetaMask/core/pull/9629), [#9735](https://github.com/MetaMask/core/pull/9735), [#9809](https://github.com/MetaMask/core/pull/9809), [#9903](https://github.com/MetaMask/core/pull/9903)) +- Bump `@metamask/wallet` from `^3.0.0` to `^10.0.0` ([#9218](https://github.com/MetaMask/core/pull/9218), [#9263](https://github.com/MetaMask/core/pull/9263), [#9349](https://github.com/MetaMask/core/pull/9349), [#9396](https://github.com/MetaMask/core/pull/9396), [#9470](https://github.com/MetaMask/core/pull/9470), [#9609](https://github.com/MetaMask/core/pull/9609), [#9629](https://github.com/MetaMask/core/pull/9629), [#9735](https://github.com/MetaMask/core/pull/9735), [#9809](https://github.com/MetaMask/core/pull/9809)) - Wrap daemon password and SRP in opaque `Password` and `Srp` types that redact on logging; validated and unwrapped only at trust boundaries ([#8863](https://github.com/MetaMask/core/pull/8863)) - Bump `@metamask/analytics-controller` from `^1.2.1` to `^2.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) - Bump `@metamask/remote-feature-flag-controller` from `^4.2.2` to `^5.0.0` ([#9735](https://github.com/MetaMask/core/pull/9735)) diff --git a/packages/wallet-cli/package.json b/packages/wallet-cli/package.json index 73c13d01d4b..ed45724e902 100644 --- a/packages/wallet-cli/package.json +++ b/packages/wallet-cli/package.json @@ -51,7 +51,6 @@ "@inquirer/password": "^5.1.1", "@metamask/analytics-controller": "^2.0.0", "@metamask/base-controller": "^9.1.0", - "@metamask/config-registry-controller": "^3.0.0", "@metamask/messenger": "^2.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", "@metamask/rpc-errors": "^7.0.2", @@ -59,7 +58,7 @@ "@metamask/storage-service": "^1.0.2", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@metamask/wallet": "^11.0.0", + "@metamask/wallet": "^10.0.0", "@oclif/core": "^4.10.5", "better-sqlite3": "^12.9.0", "immer": "^9.0.6" diff --git a/packages/wallet-cli/src/daemon/wallet-factory.ts b/packages/wallet-cli/src/daemon/wallet-factory.ts index 43d26d313b6..168ddefd937 100644 --- a/packages/wallet-cli/src/daemon/wallet-factory.ts +++ b/packages/wallet-cli/src/daemon/wallet-factory.ts @@ -3,7 +3,6 @@ import type { AnalyticsControllerGetStateAction, AnalyticsControllerTrackEventAction, } from '@metamask/analytics-controller'; -import { ConfigRegistryApiEnv } from '@metamask/config-registry-controller'; import { Messenger } from '@metamask/messenger'; import { ClientConfigApiService, @@ -95,9 +94,6 @@ function buildInstanceOptions( infuraProjectId: string, ): WalletOptions['instanceOptions'] { return { - configRegistryApiService: { - env: ConfigRegistryApiEnv.PRD, - }, approvalController: { // The daemon is headless, so there is no UI to open: requests are // resolved by the auto-approval subscription (see `subscribeToAutoApproval`) diff --git a/packages/wallet-cli/tsconfig.build.json b/packages/wallet-cli/tsconfig.build.json index 473d52f1979..63ff7baf19a 100644 --- a/packages/wallet-cli/tsconfig.build.json +++ b/packages/wallet-cli/tsconfig.build.json @@ -12,7 +12,6 @@ { "path": "../base-controller/tsconfig.build.json" }, - { "path": "../config-registry-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" diff --git a/packages/wallet-cli/tsconfig.json b/packages/wallet-cli/tsconfig.json index effbe032d23..d42e2c80a6c 100644 --- a/packages/wallet-cli/tsconfig.json +++ b/packages/wallet-cli/tsconfig.json @@ -10,9 +10,6 @@ { "path": "../base-controller" }, - { - "path": "../config-registry-controller" - }, { "path": "../messenger" }, diff --git a/packages/wallet-framework-docs/package.json b/packages/wallet-framework-docs/package.json index de22720ca99..d0c1cddab4c 100644 --- a/packages/wallet-framework-docs/package.json +++ b/packages/wallet-framework-docs/package.json @@ -42,7 +42,7 @@ "@metamask/messenger": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", - "@tanstack/query-core": "^5.62.16" + "@tanstack/query-core": "^4.43.0" }, "devDependencies": { "@docusaurus/core": "^3.10.1", diff --git a/packages/wallet/CHANGELOG.md b/packages/wallet/CHANGELOG.md index ad73092ba2d..288264c3821 100644 --- a/packages/wallet/CHANGELOG.md +++ b/packages/wallet/CHANGELOG.md @@ -7,24 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Added - -- Add optional `instanceOptions.remoteFeatureFlagController.getCanonicalProfileId` constructor option to `RemoteFeatureFlagController` for threshold flag segmentation ([#9325](https://github.com/MetaMask/core/pull/9325)) -- Add optional `instanceOptions.remoteFeatureFlagController.metaMetricsFlags` constructor option to `RemoteFeatureFlagController` to segment flags by MetaMetrics ID ([#9325](https://github.com/MetaMask/core/pull/9325)) -- **BREAKING:** Wire `ConfigRegistryApiService` and `ConfigRegistryController` into the default wallet initialization ([#9928](https://github.com/MetaMask/core/pull/9928)) - - Adds a required `configRegistryApiService` slot to `instanceOptions` with a required `env` (`ConfigRegistryApiEnv`) and optional `fetch` and `policyOptions`. `fetch` defaults to `globalThis.fetch` via `ConfigRegistryApiService`. - - Adds an optional `configRegistryController` slot to `instanceOptions` for optional `pollingInterval` and `fallbackConfig`. - - `ConfigRegistryController` delegates `KeyringController:getState`, `RemoteFeatureFlagController:getState`, and `ConfigRegistryApiService:fetchConfig`, and subscribes to `KeyringController:unlock`, `KeyringController:lock`, and `RemoteFeatureFlagController:stateChange`. - - Consumers that pass their own root messenger and already wire `ConfigRegistryApiService` / `ConfigRegistryController` must remove their own before upgrading, or the duplicate registration will collide. - -## [11.0.0] - ### Changed -- **BREAKING:** Bump `@metamask/subscription-controller` from `^7.0.0` to `^8.0.0` ([#9903](https://github.com/MetaMask/core/pull/9903)) - - Exported `DefaultActions` and root-messenger `SubscriptionController:*` actions pick up the 8.0.0 renames: `SubscriptionController:startShieldSubscriptionWithCard` is now `SubscriptionController:startSubscriptionWithCard`, and `SubscriptionController:submitShieldSubscriptionCryptoApproval` is now `SubscriptionController:submitSubscriptionCryptoApproval`. - - `submitSubscriptionCryptoApproval` and `cacheLastSelectedPaymentMethod` now take a single request object instead of positional arguments. - - Exported `DefaultState['SubscriptionController']` changes: `lastSelectedPaymentMethod` is `Partial`, `PricingPaymentMethod` and `TokenPaymentInfo` are discriminated unions, and `TokenPaymentInfo.conversionRate` is optional. Narrow with `type === 'crypto'` / `isVaultShare === true` before reading crypto- or vault-only fields. - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) ## [10.0.0] @@ -188,8 +172,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#8838](https://github.com/MetaMask/core/pull/8838)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/wallet@11.0.0...HEAD -[11.0.0]: https://github.com/MetaMask/core/compare/@metamask/wallet@10.0.0...@metamask/wallet@11.0.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/wallet@10.0.0...HEAD [10.0.0]: https://github.com/MetaMask/core/compare/@metamask/wallet@9.0.0...@metamask/wallet@10.0.0 [9.0.0]: https://github.com/MetaMask/core/compare/@metamask/wallet@8.1.0...@metamask/wallet@9.0.0 [8.1.0]: https://github.com/MetaMask/core/compare/@metamask/wallet@8.0.0...@metamask/wallet@8.1.0 diff --git a/packages/wallet/package.json b/packages/wallet/package.json index 3180e84cca0..9e4bc1f0102 100644 --- a/packages/wallet/package.json +++ b/packages/wallet/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/wallet", - "version": "11.0.0", + "version": "10.0.0", "description": "Provides a shared framework for building MetaMask wallets", "keywords": [ "Ethereum", @@ -61,7 +61,6 @@ "@metamask/base-controller": "^9.1.0", "@metamask/browser-passworder": "^6.0.0", "@metamask/claims-controller": "^0.6.0", - "@metamask/config-registry-controller": "^3.0.0", "@metamask/connectivity-controller": "^0.3.0", "@metamask/controller-utils": "^12.3.0", "@metamask/gas-fee-controller": "^26.3.1", @@ -74,7 +73,7 @@ "@metamask/seedless-onboarding-controller": "^10.1.1", "@metamask/shield-controller": "^6.0.0", "@metamask/storage-service": "^1.0.2", - "@metamask/subscription-controller": "^8.0.0", + "@metamask/subscription-controller": "^7.0.0", "@metamask/transaction-controller": "^69.5.2", "@metamask/utils": "^11.11.0" }, diff --git a/packages/wallet/src/Wallet.test.ts b/packages/wallet/src/Wallet.test.ts index d8b24b7c2b4..f3122f790bd 100644 --- a/packages/wallet/src/Wallet.test.ts +++ b/packages/wallet/src/Wallet.test.ts @@ -187,7 +187,7 @@ describe('Wallet', () => { const results = await wallet.init(); - expect(results).toHaveLength(6); + expect(results).toHaveLength(5); }); it('disallows modifying the messenger', async () => { diff --git a/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.test.ts b/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.test.ts deleted file mode 100644 index 8316328d4b2..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - ConfigRegistryApiEnv, - ConfigRegistryApiService, -} from '@metamask/config-registry-controller'; -import { Messenger } from '@metamask/messenger'; - -import { defaultConfigurations } from '../../defaults.js'; -import type { - DefaultActions, - DefaultEvents, - RootMessenger, -} from '../../defaults.js'; -import { configRegistryApiService } from './config-registry-api-service.js'; - -const MOCK_API_RESPONSE = { - data: { version: '1.0.0', timestamp: 1234567890, chains: [] }, -}; - -function getRootMessenger(): RootMessenger { - return new Messenger({ namespace: 'Root' }); -} - -function makeFetchMock(response = MOCK_API_RESPONSE): jest.Mock { - return jest - .fn() - .mockResolvedValue( - new globalThis.Response(JSON.stringify(response), { status: 200 }), - ); -} - -describe('configRegistryApiService', () => { - it('is registered as a default initialization configuration', () => { - expect(Object.values(defaultConfigurations)).toContain( - configRegistryApiService, - ); - }); - - it('initializes a ConfigRegistryApiService', () => { - const messenger = configRegistryApiService.getMessenger(getRootMessenger()); - - const instance = configRegistryApiService.init({ - state: undefined, - messenger, - options: { env: ConfigRegistryApiEnv.PRD }, - }); - - expect(instance).toBeInstanceOf(ConfigRegistryApiService); - }); - - it('uses the provided env to determine the API URL', async () => { - const fetchMock = makeFetchMock(); - const messenger = configRegistryApiService.getMessenger(getRootMessenger()); - - const instance = configRegistryApiService.init({ - state: undefined, - messenger, - options: { env: ConfigRegistryApiEnv.UAT, fetch: fetchMock }, - }); - - await instance.fetchConfig(); - - const [url] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(url).toContain(ConfigRegistryApiEnv.UAT); - }); - - it('exposes its actions through the root messenger', async () => { - const rootMessenger = getRootMessenger(); - const messenger = configRegistryApiService.getMessenger(rootMessenger); - - configRegistryApiService.init({ - state: undefined, - messenger, - options: { env: ConfigRegistryApiEnv.PRD, fetch: makeFetchMock() }, - }); - - const result = await rootMessenger.call( - 'ConfigRegistryApiService:fetchConfig', - ); - expect(result.modified).toBe(true); - }); -}); diff --git a/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.ts b/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.ts deleted file mode 100644 index d99a47d2472..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-api-service/config-registry-api-service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ConfigRegistryApiService } from '@metamask/config-registry-controller'; -import type { ConfigRegistryApiServiceMessenger } from '@metamask/config-registry-controller'; -import { Messenger } from '@metamask/messenger'; - -import type { InitializationConfiguration } from '../../types.js'; - -export type { ConfigRegistryApiServiceInstanceOptions } from './types.js'; - -export const configRegistryApiService: InitializationConfiguration< - ConfigRegistryApiService, - ConfigRegistryApiServiceMessenger -> = { - name: 'ConfigRegistryApiService', - init: ({ messenger, options }) => - new ConfigRegistryApiService({ - messenger, - env: options.env, - fetch: options.fetch, - policyOptions: options.policyOptions, - }), - getMessenger: (parent) => - new Messenger({ - namespace: 'ConfigRegistryApiService', - parent, - }), -}; diff --git a/packages/wallet/src/initialization/instances/config-registry-api-service/types.ts b/packages/wallet/src/initialization/instances/config-registry-api-service/types.ts deleted file mode 100644 index 8a6c3dccab8..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-api-service/types.ts +++ /dev/null @@ -1,17 +0,0 @@ -import type { - ConfigRegistryApiEnv, - ConfigRegistryApiService, -} from '@metamask/config-registry-controller'; - -type ConfigRegistryApiServiceOptions = ConstructorParameters< - typeof ConfigRegistryApiService ->[0]; - -/** - * Per-instance options for the wallet's `ConfigRegistryApiService`. - */ -export type ConfigRegistryApiServiceInstanceOptions = { - env: ConfigRegistryApiEnv; - fetch?: ConfigRegistryApiServiceOptions['fetch']; - policyOptions?: ConfigRegistryApiServiceOptions['policyOptions']; -}; diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts deleted file mode 100644 index 0be134937f5..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { ConfigRegistryController } from '@metamask/config-registry-controller'; -import { Messenger } from '@metamask/messenger'; - -import { defaultConfigurations } from '../../defaults.js'; -import type { - DefaultActions, - DefaultEvents, - RootMessenger, -} from '../../defaults.js'; -import { configRegistryController } from './config-registry-controller.js'; - -type ActionHandler = (...args: unknown[]) => unknown; - -type AnyMessenger = Messenger; - -function getRootMessenger(): RootMessenger { - return new Messenger({ namespace: 'Root' }); -} - -function registerActionHandler( - parent: RootMessenger, - namespace: string, - actionType: string, - handler: ActionHandler, -): void { - const messenger = new Messenger({ - namespace, - parent: parent as unknown as AnyMessenger, - }); - - ( - messenger as unknown as { - registerActionHandler(type: string, handler: ActionHandler): void; - } - ).registerActionHandler(actionType, handler); -} - -function registerDependencies( - rootMessenger: RootMessenger, -): void { - registerActionHandler( - rootMessenger, - 'KeyringController', - 'KeyringController:getState', - () => ({ isUnlocked: false }), - ); - registerActionHandler( - rootMessenger, - 'RemoteFeatureFlagController', - 'RemoteFeatureFlagController:getState', - () => ({ remoteFeatureFlags: {} }), - ); - registerActionHandler( - rootMessenger, - 'ConfigRegistryApiService', - 'ConfigRegistryApiService:fetchConfig', - async () => ({ modified: false }), - ); -} - -describe('configRegistryController', () => { - it('is registered as a default initialization configuration', () => { - expect(Object.values(defaultConfigurations)).toContain( - configRegistryController, - ); - }); - - it('initializes a ConfigRegistryController', () => { - const rootMessenger = getRootMessenger(); - registerDependencies(rootMessenger); - const messenger = configRegistryController.getMessenger(rootMessenger); - - const instance = configRegistryController.init({ - state: undefined, - messenger, - options: {}, - }); - - expect(instance).toBeInstanceOf(ConfigRegistryController); - }); - - it('initializes with default state', () => { - const rootMessenger = getRootMessenger(); - registerDependencies(rootMessenger); - const messenger = configRegistryController.getMessenger(rootMessenger); - - const instance = configRegistryController.init({ - state: undefined, - messenger, - options: {}, - }); - - expect(instance.state).toStrictEqual({ - configs: { networks: {} }, - version: null, - lastFetched: null, - etag: null, - }); - }); - - it('forwards provided state to the controller', () => { - const rootMessenger = getRootMessenger(); - registerDependencies(rootMessenger); - const messenger = configRegistryController.getMessenger(rootMessenger); - - const instance = configRegistryController.init({ - state: { version: 'v1.0.0', lastFetched: 12345 }, - messenger, - options: {}, - }); - - expect(instance.state.version).toBe('v1.0.0'); - expect(instance.state.lastFetched).toBe(12345); - }); - - it('exposes its state through the root messenger', () => { - const rootMessenger = getRootMessenger(); - registerDependencies(rootMessenger); - const messenger = configRegistryController.getMessenger(rootMessenger); - - configRegistryController.init({ - state: undefined, - messenger, - options: {}, - }); - - expect( - rootMessenger.call('ConfigRegistryController:getState'), - ).toStrictEqual({ - configs: { networks: {} }, - version: null, - lastFetched: null, - etag: null, - }); - }); -}); diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts b/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts deleted file mode 100644 index 3b79a846e4d..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-controller/config-registry-controller.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { ConfigRegistryController } from '@metamask/config-registry-controller'; -import type { ConfigRegistryControllerMessenger } from '@metamask/config-registry-controller'; -import { Messenger } from '@metamask/messenger'; - -import type { InitializationConfiguration } from '../../types.js'; - -export type { ConfigRegistryControllerInstanceOptions } from './types.js'; - -export const configRegistryController: InitializationConfiguration< - ConfigRegistryController, - ConfigRegistryControllerMessenger -> = { - name: 'ConfigRegistryController', - init: ({ state, messenger, options }) => - new ConfigRegistryController({ - messenger, - state, - pollingInterval: options.pollingInterval, - fallbackConfig: options.fallbackConfig, - }), - getMessenger: (parent) => { - const messenger: ConfigRegistryControllerMessenger = new Messenger({ - namespace: 'ConfigRegistryController', - parent, - }); - - parent.delegate({ - messenger, - actions: [ - 'KeyringController:getState', - 'RemoteFeatureFlagController:getState', - 'ConfigRegistryApiService:fetchConfig', - ], - events: [ - 'KeyringController:unlock', - 'KeyringController:lock', - // eslint-disable-next-line no-restricted-syntax - 'RemoteFeatureFlagController:stateChange', - ], - }); - - return messenger; - }, -}; diff --git a/packages/wallet/src/initialization/instances/config-registry-controller/types.ts b/packages/wallet/src/initialization/instances/config-registry-controller/types.ts deleted file mode 100644 index effdc5b076c..00000000000 --- a/packages/wallet/src/initialization/instances/config-registry-controller/types.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { ConfigRegistryController } from '@metamask/config-registry-controller'; - -type ConfigRegistryControllerOptions = ConstructorParameters< - typeof ConfigRegistryController ->[0]; - -/** - * Per-instance options for the wallet's `ConfigRegistryController`. - */ -export type ConfigRegistryControllerInstanceOptions = { - pollingInterval?: ConfigRegistryControllerOptions['pollingInterval']; - fallbackConfig?: ConfigRegistryControllerOptions['fallbackConfig']; -}; diff --git a/packages/wallet/src/initialization/instances/index.ts b/packages/wallet/src/initialization/instances/index.ts index c8f4b529ec9..0d4178b8b66 100644 --- a/packages/wallet/src/initialization/instances/index.ts +++ b/packages/wallet/src/initialization/instances/index.ts @@ -3,8 +3,6 @@ export { addressBookController } from './address-book-controller/address-book-co export { approvalController } from './approval-controller/approval-controller.js'; export { claimsController } from './claims-controller/claims-controller.js'; export { claimsService } from './claims-service/claims-service.js'; -export { configRegistryApiService } from './config-registry-api-service/config-registry-api-service.js'; -export { configRegistryController } from './config-registry-controller/config-registry-controller.js'; export { connectivityController } from './connectivity-controller/connectivity-controller.js'; export { gasFeeController } from './gas-fee-controller/gas-fee-controller.js'; export { keyringController } from './keyring-controller/keyring-controller.js'; diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts index 90b03e47f0e..24765f92e7d 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.test.ts @@ -212,7 +212,7 @@ describe('remoteFeatureFlagController', () => { ).not.toHaveBeenCalled(); }); - it('forwards defaultFeatureFlags to the controller', async () => { + it('forwards defaultFeatureFlags to the controller', () => { const messenger = remoteFeatureFlagController.getMessenger(getRootMessenger()); @@ -225,8 +225,6 @@ describe('remoteFeatureFlagController', () => { }, }); - await instance.init(); - expect(instance.state.remoteFeatureFlags).toStrictEqual({ defaultFlag: true, }); diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts index 29b29fdcd3b..5d20fde04ea 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/remote-feature-flag-controller.ts @@ -17,9 +17,6 @@ export const remoteFeatureFlagController: InitializationConfiguration< messenger, clientConfigApiService: options.clientConfigApiService, getMetaMetricsId: options.getMetaMetricsId ?? ((): string => ''), - getCanonicalProfileId: - options.getCanonicalProfileId ?? ((): string => ''), - metaMetricsFlags: options.metaMetricsFlags, clientVersion: options.clientVersion ?? '0.0.0', prevClientVersion: options.prevClientVersion, fetchInterval: options.fetchInterval, diff --git a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts index 60e0e7867fc..d6210ee0ebb 100644 --- a/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts +++ b/packages/wallet/src/initialization/instances/remote-feature-flag-controller/types.ts @@ -21,16 +21,6 @@ export type RemoteFeatureFlagControllerInstanceOptions = { * Defaults to `() => ''`. */ getMetaMetricsId?: RemoteFeatureFlagControllerOptions['getMetaMetricsId']; - /** - * Returns the canonical profile identifier used for threshold flags by - * default. Defaults to `() => ''`. - */ - getCanonicalProfileId?: RemoteFeatureFlagControllerOptions['getCanonicalProfileId']; - /** - * Names of feature flags that should use MetaMetrics ID for threshold - * assignment. Flags not listed here use the canonical profile ID. - */ - metaMetricsFlags?: RemoteFeatureFlagControllerOptions['metaMetricsFlags']; /** * The current client version for version-based flag filtering. Must be a * valid 3-part SemVer or the controller throws. Defaults to `'0.0.0'`. diff --git a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts index 429990944db..74902533940 100644 --- a/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts +++ b/packages/wallet/src/initialization/instances/subscription-controller/subscription-controller.test.ts @@ -1,9 +1,7 @@ import { Messenger } from '@metamask/messenger'; import { - Env, getDefaultSubscriptionControllerState, SubscriptionController, - SUBSCRIPTION_URL, } from '@metamask/subscription-controller'; import { defaultConfigurations } from '../../defaults.js'; @@ -205,176 +203,4 @@ describe('subscriptionController', () => { getDefaultSubscriptionControllerState(), ); }); - - it('calls generic startSubscriptionWithCard through the root messenger', async () => { - const rootMessenger = getRootMessenger(); - registerActionHandler( - rootMessenger, - 'AuthenticationController', - 'AuthenticationController:getBearerToken', - async () => 'test-bearer-token', - ); - registerActionHandler( - rootMessenger, - 'AuthenticationController', - 'AuthenticationController:getSessionProfile', - async () => ({ - profileId: 'profile-1', - canonicalProfileId: 'canonical-profile-1', - metaMetricsId: 'metametrics-1', - }), - ); - registerActionHandler( - rootMessenger, - 'AuthenticationController', - 'AuthenticationController:performSignOut', - jest.fn(), - ); - const serviceMessenger = subscriptionService.getMessenger(rootMessenger); - const fetchFunction = jest.fn(async (url: string) => { - if (url === SUBSCRIPTION_URL(Env.PRD, 'subscriptions/card')) { - return new globalThis.Response( - JSON.stringify({ - checkoutSessionUrl: 'https://checkout.example.com/session/123', - }), - { status: 200 }, - ); - } - - return new globalThis.Response( - JSON.stringify({ - customerId: 'cus_1', - subscriptions: [], - trialedProducts: [], - }), - { status: 200 }, - ); - }); - - subscriptionService.init({ - state: undefined, - messenger: serviceMessenger, - options: { - fetchFunction, - }, - }); - - const controllerMessenger = - subscriptionController.getMessenger(rootMessenger); - subscriptionController.init({ - state: { - subscriptions: [], - trialedProducts: [], - pricing: { - products: [ - { - name: 'money_account_plus', - prices: [ - { - interval: 'month', - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - trialPeriodDays: 0, - minBillingCycles: 12, - minBillingCyclesForBalance: 1, - }, - ], - }, - ], - paymentMethods: [], - }, - }, - messenger: controllerMessenger, - options: {}, - }); - - const result = await rootMessenger.call( - 'SubscriptionController:startSubscriptionWithCard', - { - products: ['money_account_plus'], - isTrialRequested: false, - recurringInterval: 'month', - }, - ); - - expect(result).toStrictEqual({ - checkoutSessionUrl: 'https://checkout.example.com/session/123', - }); - expect(fetchFunction).toHaveBeenCalledWith( - SUBSCRIPTION_URL(Env.PRD, 'subscriptions'), - expect.objectContaining({ method: 'GET' }), - ); - expect(fetchFunction).toHaveBeenCalledWith( - SUBSCRIPTION_URL(Env.PRD, 'subscriptions/card'), - expect.objectContaining({ method: 'POST' }), - ); - }); - - it('forwards dual-product initial state to the controller', () => { - const messenger = subscriptionController.getMessenger(getRootMessenger()); - - const instance = subscriptionController.init({ - state: { - subscriptions: [ - { - id: 'sub_shield', - products: [ - { - name: 'shield', - currency: 'usd', - unitAmount: 900, - unitDecimals: 2, - }, - ], - currentPeriodStart: '2024-01-01T00:00:00Z', - currentPeriodEnd: '2024-02-01T00:00:00Z', - status: 'active', - interval: 'month', - paymentMethod: { - type: 'card', - card: { - brand: 'visa', - displayBrand: 'visa', - last4: '1234', - }, - }, - isEligibleForSupport: true, - cancelType: 'allowed_at_period_end', - }, - { - id: 'sub_money_account', - products: [ - { - name: 'money_account_plus', - currency: 'usd', - unitAmount: 499, - unitDecimals: 2, - }, - ], - currentPeriodStart: '2024-01-01T00:00:00Z', - currentPeriodEnd: '2024-02-01T00:00:00Z', - status: 'active', - interval: 'month', - paymentMethod: { - type: 'crypto', - crypto: { - payerAddress: '0x1234567890123456789012345678901234567890', - chainId: '0x8f', - tokenSymbol: 'pvmUSD', - }, - }, - isEligibleForSupport: false, - cancelType: 'allowed_at_period_end', - }, - ], - trialedProducts: ['shield'], - }, - messenger, - options: {}, - }); - - expect(instance.state.subscriptions).toHaveLength(2); - expect(instance.state.trialedProducts).toStrictEqual(['shield']); - }); }); diff --git a/packages/wallet/src/types.ts b/packages/wallet/src/types.ts index 426ff0df53b..293d3b0f6a1 100644 --- a/packages/wallet/src/types.ts +++ b/packages/wallet/src/types.ts @@ -7,8 +7,6 @@ import type { } from './initialization/defaults.js'; import type { ApprovalControllerInstanceOptions } from './initialization/instances/approval-controller/types.js'; import type { ClaimsServiceInstanceOptions } from './initialization/instances/claims-service/types.js'; -import type { ConfigRegistryApiServiceInstanceOptions } from './initialization/instances/config-registry-api-service/types.js'; -import type { ConfigRegistryControllerInstanceOptions } from './initialization/instances/config-registry-controller/types.js'; import type { ConnectivityControllerInstanceOptions } from './initialization/instances/connectivity-controller/types.js'; import type { GasFeeControllerInstanceOptions } from './initialization/instances/gas-fee-controller/types.js'; import type { KeyringControllerInstanceOptions } from './initialization/instances/keyring-controller/types.js'; @@ -37,8 +35,6 @@ export type WalletOptions = { export type InstanceSpecificOptions = { approvalController?: ApprovalControllerInstanceOptions; claimsService?: ClaimsServiceInstanceOptions; - configRegistryApiService: ConfigRegistryApiServiceInstanceOptions; - configRegistryController?: ConfigRegistryControllerInstanceOptions; connectivityController: ConnectivityControllerInstanceOptions; gasFeeController: GasFeeControllerInstanceOptions; keyringController?: KeyringControllerInstanceOptions; diff --git a/packages/wallet/tsconfig.build.json b/packages/wallet/tsconfig.build.json index 8c9938a815c..5feaf09c617 100644 --- a/packages/wallet/tsconfig.build.json +++ b/packages/wallet/tsconfig.build.json @@ -10,7 +10,6 @@ { "path": "../address-book-controller/tsconfig.build.json" }, { "path": "../approval-controller/tsconfig.build.json" }, { "path": "../claims-controller/tsconfig.build.json" }, - { "path": "../config-registry-controller/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../connectivity-controller/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, diff --git a/packages/wallet/tsconfig.json b/packages/wallet/tsconfig.json index db852803a94..88b74155e3b 100644 --- a/packages/wallet/tsconfig.json +++ b/packages/wallet/tsconfig.json @@ -16,9 +16,6 @@ { "path": "../claims-controller" }, - { - "path": "../config-registry-controller" - }, { "path": "../base-controller" }, diff --git a/teams.json b/teams.json index c088f86ae27..c5ef96c8b6f 100644 --- a/teams.json +++ b/teams.json @@ -13,6 +13,7 @@ "metamask/network-enablement-controller": "team-assets", "metamask/address-book-controller": "team-confirmations", "metamask/approval-controller": "team-confirmations", + "metamask/ens-controller": "team-confirmations", "metamask/gas-fee-controller": "team-confirmations", "metamask/logging-controller": "team-confirmations", "metamask/message-manager": "team-confirmations", diff --git a/tsconfig.build.json b/tsconfig.build.json index 01a1a01b606..b78696aa63a 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -109,6 +109,9 @@ { "path": "./packages/eip1193-permission-middleware/tsconfig.build.json" }, + { + "path": "./packages/ens-controller/tsconfig.build.json" + }, { "path": "./packages/eth-block-tracker/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index accc0479c8d..01ff9f76def 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -104,6 +104,9 @@ { "path": "./packages/eip1193-permission-middleware" }, + { + "path": "./packages/ens-controller" + }, { "path": "./packages/eth-block-tracker" }, diff --git a/yarn.config.cjs b/yarn.config.cjs index 2b55c2fd360..1e1dc0fbf90 100644 --- a/yarn.config.cjs +++ b/yarn.config.cjs @@ -23,7 +23,9 @@ const { inspect } = require('util'); * Only intended as temporary measures to faciliate upgrades and releases. * This should trend towards empty. */ -const ALLOWED_INCONSISTENT_DEPENDENCIES = {}; +const ALLOWED_INCONSISTENT_DEPENDENCIES = { + '@tanstack/query-core': ['^4.43.0'], +}; /** * These packages are allowed as peer dependencies without requiring installation as diff --git a/yarn.lock b/yarn.lock index 2d3cb3e1765..467ee181753 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5735,7 +5735,7 @@ __metadata: languageName: node linkType: hard -"@metamask/account-tree-controller@npm:^8.0.0, @metamask/account-tree-controller@workspace:packages/account-tree-controller": +"@metamask/account-tree-controller@npm:^7.6.1, @metamask/account-tree-controller@workspace:packages/account-tree-controller": version: 0.0.0-use.local resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: @@ -5743,11 +5743,10 @@ __metadata: "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-hd-keyring": "npm:^15.0.0" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" - "@metamask/multichain-account-service": "npm:^13.0.2" + "@metamask/multichain-account-service": "npm:^13.0.1" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/snaps-controllers": "npm:^19.0.0" @@ -5979,29 +5978,29 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^14.0.0, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^13.1.2, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: "@ethereumjs/util": "npm:^9.1.0" "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^8.0.0" + "@metamask/account-tree-controller": "npm:^7.6.1" "@metamask/accounts-controller": "npm:^39.1.0" - "@metamask/assets-controllers": "npm:^111.1.1" + "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" - "@metamask/config-registry-controller": "npm:^3.0.0" + "@metamask/config-registry-controller": "npm:^2.0.1" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^8.1.2" + "@metamask/core-backend": "npm:^8.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/keyring-snap-client": "npm:^10.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" - "@metamask/network-enablement-controller": "npm:^6.0.4" + "@metamask/network-enablement-controller": "npm:^6.0.3" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.3.1" "@metamask/polling-controller": "npm:^16.0.9" @@ -6028,7 +6027,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^111.1.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^111.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -6041,14 +6040,14 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-api": "npm:^2.0.0" - "@metamask/account-tree-controller": "npm:^8.0.0" + "@metamask/account-tree-controller": "npm:^7.6.1" "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^8.1.2" + "@metamask/core-backend": "npm:^8.1.1" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" "@metamask/keyring-api": "npm:^24.0.0" @@ -6057,9 +6056,9 @@ __metadata: "@metamask/keyring-snap-client": "npm:^10.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/multichain-account-service": "npm:^13.0.2" + "@metamask/multichain-account-service": "npm:^13.0.1" "@metamask/network-controller": "npm:^35.0.1" - "@metamask/network-enablement-controller": "npm:^6.0.4" + "@metamask/network-enablement-controller": "npm:^6.0.3" "@metamask/permission-controller": "npm:^13.1.1" "@metamask/phishing-controller": "npm:^17.3.1" "@metamask/polling-controller": "npm:^16.0.9" @@ -6213,9 +6212,8 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" @@ -6253,7 +6251,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^79.3.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^79.2.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -6263,8 +6261,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^39.1.0" - "@metamask/assets-controller": "npm:^14.0.0" - "@metamask/assets-controllers": "npm:^111.1.1" + "@metamask/assets-controller": "npm:^13.1.2" + "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -6307,7 +6305,7 @@ __metadata: "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^79.3.0" + "@metamask/bridge-controller": "npm:^79.2.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" "@metamask/keyring-controller": "npm:^27.1.1" @@ -6398,7 +6396,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6425,7 +6423,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6466,7 +6464,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^8.1.2" + "@metamask/core-backend": "npm:^8.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/slip44": "npm:^4.3.0" "@metamask/transaction-controller": "npm:^69.5.2" @@ -6529,7 +6527,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/config-registry-controller@npm:^3.0.0, @metamask/config-registry-controller@workspace:packages/config-registry-controller": +"@metamask/config-registry-controller@npm:^2.0.1, @metamask/config-registry-controller@workspace:packages/config-registry-controller": version: 0.0.0-use.local resolution: "@metamask/config-registry-controller@workspace:packages/config-registry-controller" dependencies: @@ -6540,6 +6538,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" + "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" @@ -6640,11 +6639,11 @@ __metadata: languageName: unknown linkType: soft -"@metamask/core-backend@npm:^8.1.2, @metamask/core-backend@workspace:packages/core-backend": +"@metamask/core-backend@npm:^8.1.1, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" dependencies: - "@metamask/account-tree-controller": "npm:^8.0.0" + "@metamask/account-tree-controller": "npm:^7.6.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-controller": "npm:^27.1.1" @@ -6807,7 +6806,7 @@ __metadata: dependencies: "@ethersproject/bignumber": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/account-tree-controller": "npm:^8.0.0" + "@metamask/account-tree-controller": "npm:^7.6.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -6901,6 +6900,30 @@ __metadata: languageName: unknown linkType: soft +"@metamask/ens-controller@workspace:packages/ens-controller": + version: 0.0.0-use.local + resolution: "@metamask/ens-controller@workspace:packages/ens-controller" + dependencies: + "@ethersproject/providers": "npm:^5.7.0" + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/network-controller": "npm:^35.0.1" + "@metamask/utils": "npm:^11.11.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^30.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^30.4.2" + punycode: "npm:^2.1.1" + ts-jest: "npm:^29.4.11" + 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/eslint-config-jest@npm:^15.0.0": version: 15.0.0 resolution: "@metamask/eslint-config-jest@npm:15.0.0" @@ -7673,7 +7696,7 @@ __metadata: "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@scure/base": "npm:^1.2.6" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" chokidar-cli: "npm:^3.0.0" @@ -7840,7 +7863,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -7937,7 +7960,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-utils@workspace:packages/money-account-utils": +"@metamask/money-account-utils@npm:^1.1.0, @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: @@ -7959,7 +7982,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/multichain-account-service@npm:^13.0.2, @metamask/multichain-account-service@workspace:packages/multichain-account-service": +"@metamask/multichain-account-service@npm:^13.0.1, @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" dependencies: @@ -8136,7 +8159,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" - "@metamask/network-enablement-controller": "npm:^6.0.4" + "@metamask/network-enablement-controller": "npm:^6.0.3" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8201,13 +8224,13 @@ __metadata: languageName: unknown linkType: soft -"@metamask/network-enablement-controller@npm:^6.0.4, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": +"@metamask/network-enablement-controller@npm:^6.0.3, @metamask/network-enablement-controller@workspace:packages/network-enablement-controller": version: 0.0.0-use.local resolution: "@metamask/network-enablement-controller@workspace:packages/network-enablement-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/config-registry-controller": "npm:^3.0.0" + "@metamask/config-registry-controller": "npm:^2.0.1" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/messenger": "npm:^2.0.0" @@ -8403,7 +8426,7 @@ __metadata: resolution: "@metamask/perps-controller@workspace:packages/perps-controller" dependencies: "@metamask/abi-utils": "npm:^2.0.3" - "@metamask/account-tree-controller": "npm:^8.0.0" + "@metamask/account-tree-controller": "npm:^7.6.1" "@metamask/authenticated-user-storage": "npm:^3.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -8512,6 +8535,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/network-controller": "npm:^35.0.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8601,17 +8625,17 @@ __metadata: "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/eth-hd-keyring": "npm:^15.0.0" - "@metamask/key-tree": "npm:^10.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/keyring-internal-api": "npm:^12.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/providers": "npm:^22.1.0" "@metamask/seedless-onboarding-controller": "npm:^10.1.1" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/utils": "npm:^11.11.0" "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8785,7 +8809,7 @@ __metadata: "@metamask/network-controller": "npm:^35.0.1" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -8881,7 +8905,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -8911,7 +8935,7 @@ __metadata: "@metamask/signature-controller": "npm:^39.2.9" "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" cockatiel: "npm:^3.1.2" @@ -9243,7 +9267,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/subscription-controller@npm:^8.0.0, @metamask/subscription-controller@workspace:packages/subscription-controller": +"@metamask/subscription-controller@npm:^7.0.0, @metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: @@ -9257,7 +9281,7 @@ __metadata: "@metamask/superstruct": "npm:^3.4.1" "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" @@ -9321,7 +9345,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^8.1.2" + "@metamask/core-backend": "npm:^8.1.1" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" "@metamask/ethjs-provider-http": "npm:^0.3.0" @@ -9368,8 +9392,8 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^14.0.0" - "@metamask/assets-controllers": "npm:^111.1.1" + "@metamask/assets-controller": "npm:^13.1.2" + "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -9377,6 +9401,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/money-account-utils": "npm:^1.1.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" @@ -9481,7 +9506,6 @@ __metadata: "@metamask/analytics-controller": "npm:^2.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/config-registry-controller": "npm:^3.0.0" "@metamask/foundryup": "npm:^1.0.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" @@ -9490,7 +9514,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.2" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@metamask/wallet": "npm:^11.0.0" + "@metamask/wallet": "npm:^10.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/better-sqlite3": "npm:^7.6.13" @@ -9526,7 +9550,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.0.0" deepmerge: "npm:^4.2.2" @@ -9542,7 +9566,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@npm:^11.0.0, @metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^10.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -9553,7 +9577,6 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/claims-controller": "npm:^0.6.0" - "@metamask/config-registry-controller": "npm:^3.0.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" @@ -9566,7 +9589,7 @@ __metadata: "@metamask/seedless-onboarding-controller": "npm:^10.1.1" "@metamask/shield-controller": "npm:^6.0.0" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/subscription-controller": "npm:^8.0.0" + "@metamask/subscription-controller": "npm:^7.0.0" "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" @@ -11524,6 +11547,13 @@ __metadata: languageName: node linkType: hard +"@tanstack/query-core@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/query-core@npm:4.43.0" + checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 + languageName: node + linkType: hard + "@tanstack/react-query@npm:^5.62.16": version: 5.101.2 resolution: "@tanstack/react-query@npm:5.101.2"