From aba2cdedf122904e0fd7a8cc9a207f0a027366ed Mon Sep 17 00:00:00 2001 From: Tuna Date: Thu, 27 Aug 2026 16:55:11 +0700 Subject: [PATCH 1/8] feat: init money account subscription controller --- .github/CODEOWNERS | 1 + README.md | 2 + .../CHANGELOG.md | 10 + .../LICENSE | 6 + .../LICENSE.APACHE2 | 201 ++++++++++++++++++ .../LICENSE.MIT | 21 ++ .../README.md | 15 ++ .../jest.config.js | 26 +++ .../package.json | 70 ++++++ .../src/index.test.ts | 9 + .../src/index.ts | 9 + .../tsconfig.build.json | 10 + .../tsconfig.json | 8 + .../typedoc.json | 7 + tsconfig.build.json | 7 +- tsconfig.json | 31 +-- yarn.lock | 17 ++ 17 files changed, 434 insertions(+), 16 deletions(-) create mode 100644 packages/money-account-subscription-controller/CHANGELOG.md create mode 100644 packages/money-account-subscription-controller/LICENSE create mode 100644 packages/money-account-subscription-controller/LICENSE.APACHE2 create mode 100644 packages/money-account-subscription-controller/LICENSE.MIT create mode 100644 packages/money-account-subscription-controller/README.md create mode 100644 packages/money-account-subscription-controller/jest.config.js create mode 100644 packages/money-account-subscription-controller/package.json create mode 100644 packages/money-account-subscription-controller/src/index.test.ts create mode 100644 packages/money-account-subscription-controller/src/index.ts create mode 100644 packages/money-account-subscription-controller/tsconfig.build.json create mode 100644 packages/money-account-subscription-controller/tsconfig.json create mode 100644 packages/money-account-subscription-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a2047d1fa8a..22587904ed7 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -126,6 +126,7 @@ /packages/shield-controller @MetaMask/web3auth /packages/subscription-controller @MetaMask/web3auth /packages/claims-controller @MetaMask/web3auth +/packages/money-account-subscription-controller @MetaMask/web3auth ## Universal KYC Team /packages/kyc-controller @MetaMask/universal-kyc diff --git a/README.md b/README.md index 7ea1324a138..53266dad23f 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/money-account-api-data-service`](packages/money-account-api-data-service) - [`@metamask/money-account-balance-service`](packages/money-account-balance-service) - [`@metamask/money-account-controller`](packages/money-account-controller) +- [`@metamask/money-account-subscription-controller`](packages/money-account-subscription-controller) - [`@metamask/money-account-upgrade-controller`](packages/money-account-upgrade-controller) - [`@metamask/money-account-utils`](packages/money-account-utils) - [`@metamask/multichain-account-service`](packages/multichain-account-service) @@ -200,6 +201,7 @@ linkStyle default opacity:0.5 money_account_api_data_service(["@metamask/money-account-api-data-service"]); money_account_balance_service(["@metamask/money-account-balance-service"]); money_account_controller(["@metamask/money-account-controller"]); + money_account_subscription_controller(["@metamask/money-account-subscription-controller"]); money_account_upgrade_controller(["@metamask/money-account-upgrade-controller"]); money_account_utils(["@metamask/money-account-utils"]); multichain_account_service(["@metamask/multichain-account-service"]); diff --git a/packages/money-account-subscription-controller/CHANGELOG.md b/packages/money-account-subscription-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/money-account-subscription-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-subscription-controller/LICENSE b/packages/money-account-subscription-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/money-account-subscription-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/money-account-subscription-controller/LICENSE.APACHE2 b/packages/money-account-subscription-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/money-account-subscription-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/money-account-subscription-controller/LICENSE.MIT b/packages/money-account-subscription-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/money-account-subscription-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/money-account-subscription-controller/README.md b/packages/money-account-subscription-controller/README.md new file mode 100644 index 00000000000..b3f1e9ce06c --- /dev/null +++ b/packages/money-account-subscription-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/money-account-subscription-controller` + +feature controller for money account subscription + +## Installation + +`yarn add @metamask/money-account-subscription-controller` + +or + +`npm install @metamask/money-account-subscription-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/money-account-subscription-controller/jest.config.js b/packages/money-account-subscription-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/money-account-subscription-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/money-account-subscription-controller/package.json b/packages/money-account-subscription-controller/package.json new file mode 100644 index 00000000000..1061fdfec78 --- /dev/null +++ b/packages/money-account-subscription-controller/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/money-account-subscription-controller", + "version": "0.0.0", + "description": "feature controller for money account subscription", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/money-account-subscription-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-subscription-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-subscription-controller", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "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/money-account-subscription-controller/src/index.test.ts b/packages/money-account-subscription-controller/src/index.test.ts new file mode 100644 index 00000000000..baa030ac10f --- /dev/null +++ b/packages/money-account-subscription-controller/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from './index.js'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/money-account-subscription-controller/src/index.ts b/packages/money-account-subscription-controller/src/index.ts new file mode 100644 index 00000000000..6972c117292 --- /dev/null +++ b/packages/money-account-subscription-controller/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/money-account-subscription-controller/tsconfig.build.json b/packages/money-account-subscription-controller/tsconfig.build.json new file mode 100644 index 00000000000..02a0eea03fe --- /dev/null +++ b/packages/money-account-subscription-controller/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-subscription-controller/tsconfig.json b/packages/money-account-subscription-controller/tsconfig.json new file mode 100644 index 00000000000..025ba2ef7f4 --- /dev/null +++ b/packages/money-account-subscription-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [], + "include": ["../../types", "./src"] +} diff --git a/packages/money-account-subscription-controller/typedoc.json b/packages/money-account-subscription-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/money-account-subscription-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 01a1a01b606..6006d106c66 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -164,14 +164,17 @@ "path": "./packages/money-account-api-data-service/tsconfig.build.json" }, { - "path": "./packages/money-account-balance-service/tsconfig.build.json" + "path": "./packages/money-account-api-data-service/tsconfig.build.json" }, { - "path": "./packages/money-account-api-data-service/tsconfig.build.json" + "path": "./packages/money-account-balance-service/tsconfig.build.json" }, { "path": "./packages/money-account-controller/tsconfig.build.json" }, + { + "path": "./packages/money-account-subscription-controller/tsconfig.build.json" + }, { "path": "./packages/money-account-upgrade-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index accc0479c8d..870a4e05093 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -101,6 +101,12 @@ { "path": "./packages/earn-controller" }, + { + "path": "./packages/eip-5792-middleware" + }, + { + "path": "./packages/eip-7702-internal-rpc-middleware" + }, { "path": "./packages/eip1193-permission-middleware" }, @@ -143,6 +149,9 @@ { "path": "./packages/local-node-utils" }, + { + "path": "./packages/logging-controller" + }, { "path": "./packages/message-manager" }, @@ -156,14 +165,17 @@ "path": "./packages/money-account-api-data-service" }, { - "path": "./packages/money-account-balance-service" + "path": "./packages/money-account-api-data-service" }, { - "path": "./packages/money-account-api-data-service" + "path": "./packages/money-account-balance-service" }, { "path": "./packages/money-account-controller" }, + { + "path": "./packages/money-account-subscription-controller" + }, { "path": "./packages/money-account-upgrade-controller" }, @@ -272,6 +284,9 @@ { "path": "./packages/stellar-quickstart-up" }, + { + "path": "./packages/storage-service" + }, { "path": "./packages/subscription-controller" }, @@ -284,18 +299,6 @@ { "path": "./packages/user-operation-controller" }, - { - "path": "./packages/eip-5792-middleware" - }, - { - "path": "./packages/eip-7702-internal-rpc-middleware" - }, - { - "path": "./packages/logging-controller" - }, - { - "path": "./packages/storage-service" - }, { "path": "./packages/wallet" }, diff --git a/yarn.lock b/yarn.lock index ce9edb7e018..9dae6298eea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7909,6 +7909,23 @@ __metadata: languageName: unknown linkType: soft +"@metamask/money-account-subscription-controller@workspace:packages/money-account-subscription-controller": + version: 0.0.0-use.local + resolution: "@metamask/money-account-subscription-controller@workspace:packages/money-account-subscription-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^30.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^30.4.2" + 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/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller": version: 0.0.0-use.local resolution: "@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller" From cad166a5b2833b910792dc25ba6062f7fb84da24 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 31 Aug 2026 15:10:20 +0700 Subject: [PATCH 2/8] feat(money-account-subscription-controller): add MoneyAccountSubscriptionController with JWT handling --- .github/CODEOWNERS | 405 ++-- codeowners.ts | 5 + .../CHANGELOG.md | 8 + .../README.md | 28 +- .../package.json | 16 +- ...scriptionController-method-action-types.ts | 35 + ...MoneyAccountSubscriptionController.test.ts | 1758 +++++++++++++++++ .../src/MoneyAccountSubscriptionController.ts | 625 ++++++ .../src/index.test.ts | 9 - .../src/index.ts | 49 +- .../src/jwt-claims.test.ts | 122 ++ .../src/jwt-claims.ts | 98 + .../src/selectors.test.ts | 77 + .../src/selectors.ts | 33 + .../src/types.test.ts | 34 + .../src/types.ts | 56 + .../tsconfig.build.json | 18 +- .../tsconfig.json | 18 +- teams.json | 1 + yarn.lock | 6 + 20 files changed, 3176 insertions(+), 225 deletions(-) create mode 100644 packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController-method-action-types.ts create mode 100644 packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts create mode 100644 packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts delete mode 100644 packages/money-account-subscription-controller/src/index.test.ts create mode 100644 packages/money-account-subscription-controller/src/jwt-claims.test.ts create mode 100644 packages/money-account-subscription-controller/src/jwt-claims.ts create mode 100644 packages/money-account-subscription-controller/src/selectors.test.ts create mode 100644 packages/money-account-subscription-controller/src/selectors.ts create mode 100644 packages/money-account-subscription-controller/src/types.test.ts create mode 100644 packages/money-account-subscription-controller/src/types.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 22587904ed7..8689ea9063c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -121,12 +121,12 @@ /packages/wallet-framework-docs @MetaMask/core-platform ## Web3Auth Team -/packages/seedless-onboarding-controller @MetaMask/web3auth -/packages/passkey-controller @MetaMask/web3auth -/packages/shield-controller @MetaMask/web3auth -/packages/subscription-controller @MetaMask/web3auth -/packages/claims-controller @MetaMask/web3auth -/packages/money-account-subscription-controller @MetaMask/web3auth +/packages/seedless-onboarding-controller @MetaMask/web3auth +/packages/passkey-controller @MetaMask/web3auth +/packages/shield-controller @MetaMask/web3auth +/packages/subscription-controller @MetaMask/web3auth +/packages/money-account-subscription-controller @MetaMask/web3auth +/packages/claims-controller @MetaMask/web3auth ## Universal KYC Team /packages/kyc-controller @MetaMask/universal-kyc @@ -172,198 +172,201 @@ /packages/wallet/src/initialization/instances/transaction-controller/ @MetaMask/confirmations ## Package Release related -/packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/account-tree-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/account-tree-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/accounts-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/accounts-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/analytics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/analytics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/analytics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/analytics-data-regulation-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/analytics-data-regulation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/analytics-data-regulation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/address-book-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/address-book-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/address-book-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/announcement-controller/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/announcement-controller/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/announcement-controller/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/client-utils/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/client-utils/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/client-utils/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform -/packages/approval-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/approval-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/approval-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/assets-controllers/package.json @MetaMask/metamask-assets @MetaMask/core-platform -/packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform -/packages/assets-controllers/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform -/packages/assets-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform -/packages/assets-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform -/packages/assets-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform -/packages/config-registry-controller/package.json @MetaMask/networks @MetaMask/core-platform -/packages/config-registry-controller/CHANGELOG.md @MetaMask/networks @MetaMask/core-platform -/packages/config-registry-controller/tsconfig.* @MetaMask/networks @MetaMask/core-platform -/packages/delegation-controller/package.json @MetaMask/delegation @MetaMask/core-platform -/packages/delegation-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform -/packages/delegation-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform -/packages/earn-controller/package.json @MetaMask/earn @MetaMask/core-platform -/packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform -/packages/earn-controller/tsconfig.* @MetaMask/earn @MetaMask/core-platform -/packages/money-account-balance-service/package.json @MetaMask/earn @MetaMask/core-platform -/packages/money-account-balance-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform -/packages/money-account-balance-service/tsconfig.* @MetaMask/earn @MetaMask/core-platform -/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/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 -/packages/gator-permissions-controller/package.json @MetaMask/delegation @MetaMask/core-platform -/packages/gator-permissions-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform -/packages/gator-permissions-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform -/packages/geolocation-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform -/packages/geolocation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform -/packages/geolocation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform -/packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/keyring-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform -/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform -/packages/passkey-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform -/packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/logging-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/message-manager/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/message-manager/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/multichain-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/multichain-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/multichain-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/name-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/name-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/name-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/notification-services-controller/package.json @MetaMask/engagement @MetaMask/core-platform -/packages/notification-services-controller/CHANGELOG.md @MetaMask/engagement @MetaMask/core-platform -/packages/notification-services-controller/tsconfig.* @MetaMask/engagement @MetaMask/core-platform -/packages/compliance-controller/package.json @MetaMask/perps @MetaMask/core-platform -/packages/compliance-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform -/packages/compliance-controller/tsconfig.* @MetaMask/perps @MetaMask/core-platform -/packages/perps-controller/package.json @MetaMask/perps @MetaMask/core-platform -/packages/perps-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform -/packages/perps-controller/tsconfig.* @MetaMask/perps @MetaMask/core-platform -/packages/phishing-controller/package.json @MetaMask/product-safety @MetaMask/core-platform -/packages/phishing-controller/CHANGELOG.md @MetaMask/product-safety @MetaMask/core-platform -/packages/phishing-controller/tsconfig.* @MetaMask/product-safety @MetaMask/core-platform -/packages/ramps-controller/package.json @MetaMask/money-movement @MetaMask/core-platform -/packages/ramps-controller/CHANGELOG.md @MetaMask/money-movement @MetaMask/core-platform -/packages/ramps-controller/tsconfig.* @MetaMask/money-movement @MetaMask/core-platform -/packages/authenticated-user-storage/package.json @MetaMask/auth-engineers @MetaMask/core-platform -/packages/authenticated-user-storage/CHANGELOG.md @MetaMask/auth-engineers @MetaMask/core-platform -/packages/authenticated-user-storage/tsconfig.* @MetaMask/auth-engineers @MetaMask/core-platform -/packages/profile-metrics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/profile-metrics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/profile-metrics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/profile-sync-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/profile-sync-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/profile-sync-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/signature-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/signature-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/signature-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/smart-transactions-controller/package.json @MetaMask/transactions @MetaMask/core-platform -/packages/smart-transactions-controller/CHANGELOG.md @MetaMask/transactions @MetaMask/core-platform -/packages/smart-transactions-controller/tsconfig.* @MetaMask/transactions @MetaMask/core-platform -/packages/sentinel-api-service/package.json @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform -/packages/sentinel-api-service/CHANGELOG.md @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform -/packages/sentinel-api-service/tsconfig.* @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform -/packages/transaction-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/transaction-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/transaction-pay-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/transaction-pay-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/transaction-pay-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/user-operation-controller/package.json @MetaMask/confirmations @MetaMask/core-platform -/packages/user-operation-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform -/packages/user-operation-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform -/packages/multichain-transactions-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/multichain-transactions-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/multichain-transactions-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/bridge-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/bridge-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/remote-feature-flag-controller/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/storage-service/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/storage-service/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/storage-service/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform -/packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/bridge-status-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform -/packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform -/packages/app-metadata-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform -/packages/app-metadata-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform -/packages/foundryup/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/foundryup/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/foundryup/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform -/packages/bitcoin-regtest-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/bitcoin-regtest-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/bitcoin-regtest-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/java-tron-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/java-tron-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/java-tron-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/local-node-utils/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/local-node-utils/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/local-node-utils/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/solana-test-validator-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/solana-test-validator-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/solana-test-validator-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/stellar-quickstart-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/stellar-quickstart-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/stellar-quickstart-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform -/packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/core-platform -/packages/seedless-onboarding-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform -/packages/seedless-onboarding-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform -/packages/shield-controller/package.json @MetaMask/web3auth @MetaMask/core-platform -/packages/shield-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform -/packages/shield-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform -/packages/network-enablement-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform -/packages/network-enablement-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform -/packages/network-enablement-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform -/packages/subscription-controller/package.json @MetaMask/web3auth @MetaMask/core-platform -/packages/subscription-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform -/packages/subscription-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform -/packages/core-backend/package.json @MetaMask/core-platform @MetaMask/metamask-assets -/packages/core-backend/CHANGELOG.md @MetaMask/core-platform @MetaMask/metamask-assets -/packages/core-backend/tsconfig.* @MetaMask/core-platform @MetaMask/metamask-assets -/packages/claims-controller/package.json @MetaMask/web3auth @MetaMask/core-platform -/packages/claims-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform -/packages/claims-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform -/packages/ai-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform -/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform -/packages/ai-controllers/tsconfig.* @MetaMask/social-ai @MetaMask/core-platform -/packages/client-controller/package.json @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform -/packages/client-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform -/packages/client-controller/tsconfig.* @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform -/packages/social-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform -/packages/social-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform -/packages/social-controllers/tsconfig.* @MetaMask/social-ai @MetaMask/core-platform -/packages/money-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/money-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/money-account-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/chomp-api-service/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/chomp-api-service/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/chomp-api-service/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/money-account-upgrade-controller/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform -/packages/money-account-utils/package.json @MetaMask/earn @MetaMask/core-platform -/packages/money-account-utils/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform -/packages/money-account-utils/tsconfig.* @MetaMask/earn @MetaMask/core-platform -/packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/snap-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/kyc-controller/package.json @MetaMask/universal-kyc @MetaMask/core-platform -/packages/kyc-controller/CHANGELOG.md @MetaMask/universal-kyc @MetaMask/core-platform -/packages/kyc-controller/tsconfig.* @MetaMask/universal-kyc @MetaMask/core-platform \ No newline at end of file +/packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/account-tree-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/account-tree-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/accounts-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/analytics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/analytics-data-regulation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/address-book-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/address-book-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/address-book-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/announcement-controller/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/announcement-controller/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/announcement-controller/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/package.json @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/CHANGELOG.md @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/client-utils/tsconfig.* @MetaMask/core-extension-ux @MetaMask/mobile-core-ux @MetaMask/core-platform +/packages/approval-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/approval-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/approval-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/assets-controllers/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controllers/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controllers/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/assets-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/config-registry-controller/package.json @MetaMask/networks @MetaMask/core-platform +/packages/config-registry-controller/CHANGELOG.md @MetaMask/networks @MetaMask/core-platform +/packages/config-registry-controller/tsconfig.* @MetaMask/networks @MetaMask/core-platform +/packages/delegation-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform +/packages/delegation-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform +/packages/earn-controller/package.json @MetaMask/earn @MetaMask/core-platform +/packages/earn-controller/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/earn-controller/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-balance-service/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/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/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 +/packages/gator-permissions-controller/package.json @MetaMask/delegation @MetaMask/core-platform +/packages/gator-permissions-controller/CHANGELOG.md @MetaMask/delegation @MetaMask/core-platform +/packages/gator-permissions-controller/tsconfig.* @MetaMask/delegation @MetaMask/core-platform +/packages/geolocation-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform +/packages/geolocation-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform +/packages/geolocation-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform +/packages/keyring-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/keyring-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/keyring-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/passkey-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/passkey-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/logging-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/logging-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/logging-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/message-manager/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/multichain-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/name-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/name-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/name-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/notification-services-controller/package.json @MetaMask/engagement @MetaMask/core-platform +/packages/notification-services-controller/CHANGELOG.md @MetaMask/engagement @MetaMask/core-platform +/packages/notification-services-controller/tsconfig.* @MetaMask/engagement @MetaMask/core-platform +/packages/compliance-controller/package.json @MetaMask/perps @MetaMask/core-platform +/packages/compliance-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform +/packages/compliance-controller/tsconfig.* @MetaMask/perps @MetaMask/core-platform +/packages/perps-controller/package.json @MetaMask/perps @MetaMask/core-platform +/packages/perps-controller/CHANGELOG.md @MetaMask/perps @MetaMask/core-platform +/packages/perps-controller/tsconfig.* @MetaMask/perps @MetaMask/core-platform +/packages/phishing-controller/package.json @MetaMask/product-safety @MetaMask/core-platform +/packages/phishing-controller/CHANGELOG.md @MetaMask/product-safety @MetaMask/core-platform +/packages/phishing-controller/tsconfig.* @MetaMask/product-safety @MetaMask/core-platform +/packages/ramps-controller/package.json @MetaMask/money-movement @MetaMask/core-platform +/packages/ramps-controller/CHANGELOG.md @MetaMask/money-movement @MetaMask/core-platform +/packages/ramps-controller/tsconfig.* @MetaMask/money-movement @MetaMask/core-platform +/packages/authenticated-user-storage/package.json @MetaMask/auth-engineers @MetaMask/core-platform +/packages/authenticated-user-storage/CHANGELOG.md @MetaMask/auth-engineers @MetaMask/core-platform +/packages/authenticated-user-storage/tsconfig.* @MetaMask/auth-engineers @MetaMask/core-platform +/packages/profile-metrics-controller/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-metrics-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-metrics-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/profile-sync-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-sync-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-sync-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/signature-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/signature-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/signature-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/smart-transactions-controller/package.json @MetaMask/transactions @MetaMask/core-platform +/packages/smart-transactions-controller/CHANGELOG.md @MetaMask/transactions @MetaMask/core-platform +/packages/smart-transactions-controller/tsconfig.* @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/package.json @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/CHANGELOG.md @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/sentinel-api-service/tsconfig.* @MetaMask/confirmations @MetaMask/transactions @MetaMask/core-platform +/packages/transaction-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/transaction-pay-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/package.json @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/CHANGELOG.md @MetaMask/confirmations @MetaMask/core-platform +/packages/user-operation-controller/tsconfig.* @MetaMask/confirmations @MetaMask/core-platform +/packages/multichain-transactions-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-transactions-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/multichain-transactions-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/bridge-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/remote-feature-flag-controller/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/remote-feature-flag-controller/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/remote-feature-flag-controller/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/package.json @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/CHANGELOG.md @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/storage-service/tsconfig.* @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/bridge-status-controller/package.json @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-status-controller/CHANGELOG.md @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/bridge-status-controller/tsconfig.* @MetaMask/swaps-engineers @MetaMask/core-platform +/packages/app-metadata-controller/package.json @MetaMask/mobile-platform @MetaMask/core-platform +/packages/app-metadata-controller/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/core-platform +/packages/app-metadata-controller/tsconfig.* @MetaMask/mobile-platform @MetaMask/core-platform +/packages/foundryup/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/foundryup/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/foundryup/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/core-platform +/packages/bitcoin-regtest-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/bitcoin-regtest-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/bitcoin-regtest-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/java-tron-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/local-node-utils/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/solana-test-validator-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/package.json @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/CHANGELOG.md @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/stellar-quickstart-up/tsconfig.* @MetaMask/mobile-platform @MetaMask/extension-platform @MetaMask/networks @MetaMask/core-platform +/packages/seedless-onboarding-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/seedless-onboarding-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/seedless-onboarding-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/shield-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/network-enablement-controller/package.json @MetaMask/metamask-assets @MetaMask/core-platform +/packages/network-enablement-controller/CHANGELOG.md @MetaMask/metamask-assets @MetaMask/core-platform +/packages/network-enablement-controller/tsconfig.* @MetaMask/metamask-assets @MetaMask/core-platform +/packages/subscription-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/subscription-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/subscription-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/money-account-subscription-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/money-account-subscription-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/money-account-subscription-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/core-backend/package.json @MetaMask/core-platform @MetaMask/metamask-assets +/packages/core-backend/CHANGELOG.md @MetaMask/core-platform @MetaMask/metamask-assets +/packages/core-backend/tsconfig.* @MetaMask/core-platform @MetaMask/metamask-assets +/packages/claims-controller/package.json @MetaMask/web3auth @MetaMask/core-platform +/packages/claims-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform +/packages/claims-controller/tsconfig.* @MetaMask/web3auth @MetaMask/core-platform +/packages/ai-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform +/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform +/packages/ai-controllers/tsconfig.* @MetaMask/social-ai @MetaMask/core-platform +/packages/client-controller/package.json @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/client-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/client-controller/tsconfig.* @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/social-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform +/packages/social-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform +/packages/social-controllers/tsconfig.* @MetaMask/social-ai @MetaMask/core-platform +/packages/money-account-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/money-account-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/money-account-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/chomp-api-service/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/chomp-api-service/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/chomp-api-service/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-upgrade-controller/tsconfig.* @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform +/packages/money-account-utils/package.json @MetaMask/earn @MetaMask/core-platform +/packages/money-account-utils/CHANGELOG.md @MetaMask/earn @MetaMask/core-platform +/packages/money-account-utils/tsconfig.* @MetaMask/earn @MetaMask/core-platform +/packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/snap-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/kyc-controller/package.json @MetaMask/universal-kyc @MetaMask/core-platform +/packages/kyc-controller/CHANGELOG.md @MetaMask/universal-kyc @MetaMask/core-platform +/packages/kyc-controller/tsconfig.* @MetaMask/universal-kyc @MetaMask/core-platform \ No newline at end of file diff --git a/codeowners.ts b/codeowners.ts index b497380a2d9..185b2f018d1 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -362,6 +362,9 @@ const PACKAGES: Record = { teams: ['@MetaMask/web3auth'], initializationPath: 'subscription-controller', }, + 'money-account-subscription-controller': { + teams: ['@MetaMask/web3auth'], + }, 'subscription-service': { teams: ['@MetaMask/web3auth'], initializationPath: 'subscription-service', @@ -572,6 +575,7 @@ function buildTeamSections(): CodeownersSection[] { buildRuleForPackage('passkey-controller'), buildRuleForPackage('shield-controller'), buildRuleForPackage('subscription-controller'), + buildRuleForPackage('money-account-subscription-controller'), buildRuleForPackage('claims-controller'), ], }, @@ -706,6 +710,7 @@ function buildPackageReleaseSection(): CodeownersSection { 'shield-controller', 'network-enablement-controller', 'subscription-controller', + 'money-account-subscription-controller', 'core-backend', 'claims-controller', 'ai-controllers', diff --git a/packages/money-account-subscription-controller/CHANGELOG.md b/packages/money-account-subscription-controller/CHANGELOG.md index b518709c7b8..96167129ee0 100644 --- a/packages/money-account-subscription-controller/CHANGELOG.md +++ b/packages/money-account-subscription-controller/CHANGELOG.md @@ -7,4 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `MoneyAccountSubscriptionController`, a read-only polling controller that hydrates Money Account Plus plan and entitlement flags from the Profile JWT, exposes gating selectors, and supports explicit and event-driven JWT refreshes. + +### Changed + +- Decode JWT payloads with `@metamask/utils` instead of `atob`. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/money-account-subscription-controller/README.md b/packages/money-account-subscription-controller/README.md index b3f1e9ce06c..02715292797 100644 --- a/packages/money-account-subscription-controller/README.md +++ b/packages/money-account-subscription-controller/README.md @@ -1,6 +1,18 @@ # `@metamask/money-account-subscription-controller` -feature controller for money account subscription +Read-only controller that derives Money Account Plus subscription state from the +Profile JWT claim exposed through `AuthenticationController:getBearerToken`. + +The controller: + +- hydrates plan and entitlement flags from the namespaced + `productEntitlements.moneyAccountPlus` JWT claim; +- listens to `AuthenticationController:stateChanged` and + `SubscriptionController:stateChanged` to keep local state aligned with auth and + subscription changes; +- exposes pure selectors for subscriber and entitlement gating; and +- refreshes the JWT on a polling interval while an interested trading surface is + active. ## Installation @@ -10,6 +22,20 @@ or `npm install @metamask/money-account-subscription-controller` +## API + +The main exports are: + +- `MoneyAccountSubscriptionController` +- `getDefaultMoneyAccountSubscriptionControllerState()` +- `getProductEntitlementsClaimKey()` +- `decodeJwtPayload()` +- `getMoneyAccountPlusClaimFromBearerToken()` +- `selectIsActiveSubscriber()` +- `selectHasEntitlement()` +- `selectIsUsageAvailable()` +- `MoneyAccountFeature` + ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/money-account-subscription-controller/package.json b/packages/money-account-subscription-controller/package.json index 1061fdfec78..1c761e9b852 100644 --- a/packages/money-account-subscription-controller/package.json +++ b/packages/money-account-subscription-controller/package.json @@ -1,7 +1,7 @@ { "name": "@metamask/money-account-subscription-controller", "version": "0.0.0", - "description": "feature controller for money account subscription", + "description": "Read-only Money Account Plus entitlement controller", "keywords": [ "Ethereum", "MetaMask" @@ -44,14 +44,24 @@ "build:docs": "typedoc", "changelog:update": "../../scripts/update-changelog.sh @metamask/money-account-subscription-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/money-account-subscription-controller", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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": { + "@metamask/base-controller": "^9.1.0", + "@metamask/messenger": "^2.0.0", + "@metamask/polling-controller": "^16.0.9", + "@metamask/profile-sync-controller": "^29.0.0", + "@metamask/subscription-controller": "^8.0.1", + "@metamask/utils": "^11.11.0" + }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController-method-action-types.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController-method-action-types.ts new file mode 100644 index 00000000000..4da5078d592 --- /dev/null +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController-method-action-types.ts @@ -0,0 +1,35 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { MoneyAccountSubscriptionController } from './MoneyAccountSubscriptionController.js'; + +export type MoneyAccountSubscriptionControllerForceRefreshAction = { + type: `MoneyAccountSubscriptionController:forceRefresh`; + handler: MoneyAccountSubscriptionController['forceRefresh']; +}; + +export type MoneyAccountSubscriptionControllerStartEntitlementRefreshAction = { + type: `MoneyAccountSubscriptionController:startEntitlementRefresh`; + handler: MoneyAccountSubscriptionController['startEntitlementRefresh']; +}; + +export type MoneyAccountSubscriptionControllerStopEntitlementRefreshAction = { + type: `MoneyAccountSubscriptionController:stopEntitlementRefresh`; + handler: MoneyAccountSubscriptionController['stopEntitlementRefresh']; +}; + +export type MoneyAccountSubscriptionControllerClearStateAction = { + type: `MoneyAccountSubscriptionController:clearState`; + handler: MoneyAccountSubscriptionController['clearState']; +}; + +/** + * Union of all MoneyAccountSubscriptionController action types. + */ +export type MoneyAccountSubscriptionControllerMethodActions = + | MoneyAccountSubscriptionControllerForceRefreshAction + | MoneyAccountSubscriptionControllerStartEntitlementRefreshAction + | MoneyAccountSubscriptionControllerStopEntitlementRefreshAction + | MoneyAccountSubscriptionControllerClearStateAction; diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts new file mode 100644 index 00000000000..d520afd6827 --- /dev/null +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts @@ -0,0 +1,1758 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; +import { MOCK_ANY_NAMESPACE, Messenger } from '@metamask/messenger'; +import type { + MessengerActions, + MessengerEvents, + MockAnyNamespace, +} from '@metamask/messenger'; + +import { flushPromises, jestAdvanceTime } from '../../../tests/helpers.js'; +import { + getDefaultMoneyAccountSubscriptionControllerState, + MoneyAccountSubscriptionController, +} from './MoneyAccountSubscriptionController.js'; +import type { + MoneyAccountSubscriptionControllerMessenger, + MoneyAccountSubscriptionControllerState, +} from './MoneyAccountSubscriptionController.js'; +import { + selectHasEntitlement, + selectIsActiveSubscriber, +} from './selectors.js'; +import { + Env, + getProductEntitlementsClaimKey, + MoneyAccountFeature, +} from './types.js'; + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; +type RootMessenger = Messenger; + +const claimKey = getProductEntitlementsClaimKey(Env.PRD); +const MONEY_ACCOUNT_PLUS = 'money_account_plus'; +const ACTIVE_SUBSCRIPTION_STATUS = 'active'; +const CRYPTO_PAYMENT_TYPE = 'crypto'; +const MONTH_RECURRING_INTERVAL = 'month'; +const ALLOWED_AT_PERIOD_END = 'allowed_at_period_end'; + +function createBearerToken(payload: unknown): string { + return [ + Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString( + 'base64url', + ), + Buffer.from(JSON.stringify(payload)).toString('base64url'), + 'signature', + ].join('.'); +} + +function createMoneyAccountClaim( + overrides = {}, +): { + plan: string; + entitlements: { + swapFeeWaiver: boolean; + perpsFeeWaiver: boolean; + predictFreeTx: boolean; + premiumApy: boolean; + }; +} { + return { + plan: 'premium', + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: false, + predictFreeTx: true, + premiumApy: false, + }, + ...overrides, + }; +} + +function createSubscriptionState( + status = ACTIVE_SUBSCRIPTION_STATUS, +): { + customerId: string; + lastSubscription: undefined; + pricing: undefined; + rewardAccountId: undefined; + subscriptions: { + id: string; + products: { + name: typeof MONEY_ACCOUNT_PLUS; + currency: 'usd'; + unitAmount: number; + unitDecimals: number; + }[]; + currentPeriodStart: string; + currentPeriodEnd: string; + status: string; + interval: typeof MONTH_RECURRING_INTERVAL; + paymentMethod: { + type: typeof CRYPTO_PAYMENT_TYPE; + crypto: { + chainId: string; + payerAddress: string; + tokenSymbol: string; + }; + }; + isEligibleForSupport: boolean; + cancelType: typeof ALLOWED_AT_PERIOD_END; + }[]; + trialedProducts: [typeof MONEY_ACCOUNT_PLUS]; +} { + return { + customerId: 'cus_1', + lastSubscription: undefined, + pricing: undefined, + rewardAccountId: undefined, + subscriptions: [ + { + id: 'sub_money_account', + products: [ + { + name: MONEY_ACCOUNT_PLUS, + currency: 'usd', + unitAmount: 499, + unitDecimals: 2, + }, + ], + currentPeriodStart: '2026-01-01T00:00:00.000Z', + currentPeriodEnd: '2026-02-01T00:00:00.000Z', + status, + interval: MONTH_RECURRING_INTERVAL, + paymentMethod: { + type: CRYPTO_PAYMENT_TYPE, + crypto: { + chainId: '0x1', + payerAddress: '0x0000000000000000000000000000000000000001', + tokenSymbol: 'USDC', + }, + }, + isEligibleForSupport: true, + cancelType: ALLOWED_AT_PERIOD_END, + }, + ], + trialedProducts: [MONEY_ACCOUNT_PLUS], + }; +} + +function publishAuthenticationState( + rootMessenger: RootMessenger, + state: { + isSignedIn: boolean; + srpSessionData?: Record; + }, +): void { + rootMessenger.publish( + 'AuthenticationController:stateChanged', + state as never, + [] as never, + ); +} + +function publishSubscriptionState( + rootMessenger: RootMessenger, + status = ACTIVE_SUBSCRIPTION_STATUS, +): void { + publishSubscriptionPayload(rootMessenger, createSubscriptionState(status)); +} + +function publishSubscriptionPayload( + rootMessenger: RootMessenger, + state: unknown, +): void { + rootMessenger.publish( + 'SubscriptionController:stateChanged', + state as never, + [] as never, + ); +} + +function createController(options?: { + state?: Partial; + authenticationState?: { + isSignedIn: boolean; + srpSessionData?: Record; + }; + getBearerTokenImplementation?: () => Promise; + performSignOutImplementation?: () => void; + subscriptionState?: unknown; +}): { + controller: MoneyAccountSubscriptionController; + getBearerToken: jest.Mock, []>; + getAuthenticationState: jest.Mock< + { + isSignedIn: boolean; + srpSessionData?: Record; + }, + [] + >; + getSubscriptionState: jest.Mock; + messenger: MoneyAccountSubscriptionControllerMessenger; + performSignOut: jest.Mock; + rootMessenger: RootMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + + const messenger = new Messenger({ + namespace: 'MoneyAccountSubscriptionController', + parent: rootMessenger, + }) as MoneyAccountSubscriptionControllerMessenger; + + rootMessenger.delegate({ + messenger, + actions: [ + 'AuthenticationController:getState', + 'AuthenticationController:getBearerToken', + 'AuthenticationController:performSignOut', + 'SubscriptionController:getState', + ], + events: [ + 'AuthenticationController:stateChanged', + 'SubscriptionController:stateChanged', + ], + }); + + const getAuthenticationState = jest.fn(() => { + return options?.authenticationState ?? { isSignedIn: false }; + }); + const getSubscriptionState = jest.fn(() => { + return options?.subscriptionState ?? { + subscriptions: [], + trialedProducts: [], + }; + }); + const getBearerToken = jest.fn, []>(); + const performSignOut = jest.fn(); + + if (options?.getBearerTokenImplementation) { + getBearerToken.mockImplementation(options.getBearerTokenImplementation); + } + if (options?.performSignOutImplementation) { + performSignOut.mockImplementation(options.performSignOutImplementation); + } + + rootMessenger.registerActionHandler( + 'AuthenticationController:getState', + getAuthenticationState, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + getBearerToken, + ); + rootMessenger.registerActionHandler( + 'SubscriptionController:getState', + getSubscriptionState, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:performSignOut', + performSignOut, + ); + + const controller = new MoneyAccountSubscriptionController({ + messenger, + env: Env.PRD, + state: options?.state, + }); + + return { + controller, + getAuthenticationState, + getBearerToken, + getSubscriptionState, + messenger, + performSignOut, + rootMessenger, + }; +} + +function createDeferred(): { + promise: Promise; + resolve: (value: DeferredValue) => void; +} { + let resolve: (value: DeferredValue) => void = () => undefined; + + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { + promise, + resolve, + }; +} + +describe('MoneyAccountSubscriptionController', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + it('returns the default state and persists all public fields', () => { + const { controller } = createController(); + + expect(getDefaultMoneyAccountSubscriptionControllerState()).toStrictEqual({ + entitlements: null, + isSubscriber: false, + lastHydratedAt: null, + plan: null, + }); + expect( + deriveStateFromMetadata(controller.state, controller.metadata, 'persist'), + ).toStrictEqual(getDefaultMoneyAccountSubscriptionControllerState()); + }); + + it('hydrates on bootstrap when authentication is already signed in', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const claim = createMoneyAccountClaim(); + const { controller, getAuthenticationState, getBearerToken } = + createController({ + authenticationState: { + isSignedIn: true, + srpSessionData: { primary: {} }, + }, + getBearerTokenImplementation: async () => + createBearerToken({ + [claimKey]: { + moneyAccountPlus: claim, + }, + }), + }); + + await flushPromises(); + + expect(getAuthenticationState).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ + entitlements: claim.entitlements, + isSubscriber: true, + lastHydratedAt: Date.now(), + plan: claim.plan, + }); + }); + + it('stays cleared on bootstrap when authentication is signed out', async () => { + const { controller, getAuthenticationState, getBearerToken } = + createController({ + authenticationState: { + isSignedIn: false, + }, + }); + + await flushPromises(); + + expect(getAuthenticationState).toHaveBeenCalledTimes(1); + expect(getBearerToken).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('fails closed during deferred bootstrap validation of persisted entitled state', async () => { + const deferredToken = createDeferred(); + const persistedState: Partial = { + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: true, + predictFreeTx: true, + premiumApy: true, + }, + isSubscriber: true, + lastHydratedAt: 123, + plan: 'persisted', + }; + + const { controller } = createController({ + state: persistedState, + authenticationState: { + isSignedIn: true, + srpSessionData: { primary: {} }, + }, + getBearerTokenImplementation: () => deferredToken.promise, + }); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + expect(selectIsActiveSubscriber(controller.state)).toBe(false); + expect( + selectHasEntitlement( + controller.state, + MoneyAccountFeature.SwapFeeWaiver, + ), + ).toBe(false); + + await Promise.resolve(); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + + deferredToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'validated' }), + }, + }), + ); + await flushPromises(); + + expect(controller.state.plan).toBe('validated'); + expect(selectIsActiveSubscriber(controller.state)).toBe(true); + }); + + it('hydrates state from the bearer token when authentication signs in', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { controller, getBearerToken, rootMessenger } = createController(); + const claim = createMoneyAccountClaim(); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: claim, + }, + }), + ); + + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual({ + entitlements: claim.entitlements, + isSubscriber: true, + lastHydratedAt: Date.now(), + plan: claim.plan, + }); + }); + + it('clears state on authentication sign-out without fetching a token', async () => { + const { controller, getBearerToken, rootMessenger } = createController({ + state: { + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: true, + predictFreeTx: true, + premiumApy: true, + }, + isSubscriber: true, + lastHydratedAt: 123, + plan: 'premium', + }, + }); + + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + await flushPromises(); + + expect(getBearerToken).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('rehydrates from the bearer token when subscription state changes', async () => { + const { controller, getBearerToken, rootMessenger } = createController(); + const initialClaim = createMoneyAccountClaim(); + const refreshedClaim = createMoneyAccountClaim({ + entitlements: { + swapFeeWaiver: false, + perpsFeeWaiver: true, + predictFreeTx: false, + premiumApy: true, + }, + }); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: initialClaim, + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: refreshedClaim, + }, + }), + ); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state.entitlements).toStrictEqual( + refreshedClaim.entitlements, + ); + }); + + it('forces a fresh JWT only once during a sign-out/sign-in refresh cycle and throttles repeated refreshes', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + const initialClaim = createMoneyAccountClaim(); + const refreshedClaim = createMoneyAccountClaim({ + plan: 'pro', + }); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: initialClaim, + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => { + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + }); + getBearerToken.mockImplementation(async () => { + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { refreshed: true } }, + }); + + return createBearerToken({ + [claimKey]: { + moneyAccountPlus: refreshedClaim, + }, + }); + }); + + await controller.forceRefresh(); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state.plan).toBe('pro'); + + await controller.forceRefresh(); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(10_001); + + await controller.forceRefresh(); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + }); + + it('polls while a trading surface is active only for signed-in subscribers', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + + controller.startEntitlementRefresh(); + await jestAdvanceTime({ duration: 0 }); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state.isSubscriber).toBe(true); + + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + controller.stopEntitlementRefresh(); + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('does not poll while active when the current state is not entitled', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce(createBearerToken({})); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + + controller.startEntitlementRefresh(); + await jestAdvanceTime({ duration: 60_000 }); + + expect(controller.state.isSubscriber).toBe(false); + expect(performSignOut).not.toHaveBeenCalled(); + expect(getBearerToken).not.toHaveBeenCalled(); + + controller.stopEntitlementRefresh(); + }); + + it('skips direct poll execution while signed out', async () => { + const { controller, getBearerToken } = createController(); + + await controller._executePoll(); + + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('starts polling after a later sign-in when the trading surface is already active', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, rootMessenger } = createController(); + + controller.startEntitlementRefresh(); + await jestAdvanceTime({ duration: 60_000 }); + + expect(getBearerToken).not.toHaveBeenCalled(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'polled' }), + }, + }), + ); + await jestAdvanceTime({ duration: 0 }); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + controller.stopEntitlementRefresh(); + }); + + it('stops polling on entitlement loss and restarts when entitlement returns while the surface stays active', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementation(() => undefined); + controller.startEntitlementRefresh(); + getBearerToken.mockClear(); + getBearerToken.mockResolvedValue(createBearerToken({})); + await jestAdvanceTime({ duration: 0 }); + + expect(controller.state.isSubscriber).toBe(false); + + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).not.toHaveBeenCalled(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'returned' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { returned: true } }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'repolled' }), + }, + }), + ); + await jestAdvanceTime({ duration: 0 }); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + controller.stopEntitlementRefresh(); + getBearerToken.mockClear(); + await jestAdvanceTime({ duration: 60_000 }); + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('clears state through the exposed messenger action', () => { + const { controller, messenger } = createController({ + state: { + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: true, + predictFreeTx: true, + premiumApy: true, + }, + isSubscriber: true, + lastHydratedAt: 123, + plan: 'premium', + }, + }); + + messenger.call('MoneyAccountSubscriptionController:clearState'); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('ignores subscription and force refresh requests while signed out', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, rootMessenger } = createController(); + + publishSubscriptionState(rootMessenger); + await controller.forceRefresh(); + controller.startEntitlementRefresh(); + await jestAdvanceTime({ duration: 0 }); + + expect(getBearerToken).not.toHaveBeenCalled(); + + controller.stopEntitlementRefresh(); + }); + + it('does not create duplicate polling loops when refresh is started twice', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'refreshed' }), + }, + }), + ); + + controller.startEntitlementRefresh(); + controller.startEntitlementRefresh(); + await jestAdvanceTime({ duration: 0 }); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + + controller.stopEntitlementRefresh(); + }); + + it('allows stopping polling before polling has started', () => { + const { controller } = createController(); + + expect(() => controller.stopEntitlementRefresh()).not.toThrow(); + }); + + it('coalesces a second force refresh onto the in-flight refresh promise', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + const deferred = createDeferred(); + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockReturnValue(deferred.promise); + + const firstRefresh = controller.forceRefresh(); + await Promise.resolve(); + const secondRefresh = controller.forceRefresh(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + deferred.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'coalesced' }), + }, + }), + ); + + await firstRefresh; + await secondRefresh; + + expect(controller.state.plan).toBe('coalesced'); + }); + + it('does not queue another forced refresh while one is already active', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + const deferredToken = createDeferred(); + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockReturnValue(deferredToken.promise); + + const firstRefresh = controller.forceRefresh(); + await Promise.resolve(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(10_001); + const secondRefresh = controller.forceRefresh(); + + deferredToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'forced-once' }), + }, + }), + ); + + await firstRefresh; + await secondRefresh; + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + }); + + it('runs a forced refresh after an in-flight normal hydration completes', async () => { + const deferredNormalToken = createDeferred(); + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockReturnValueOnce(deferredNormalToken.promise); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await Promise.resolve(); + + getBearerToken.mockImplementationOnce(async () => + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'forced' }), + }, + }), + ); + + const forceRefresh = controller.forceRefresh(); + + deferredNormalToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'normal' }), + }, + }), + ); + + await forceRefresh; + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('forced'); + }); + + it('cancels a queued forced refresh after a later external sign-out', async () => { + const deferredNormalToken = createDeferred(); + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockReturnValueOnce(deferredNormalToken.promise); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await Promise.resolve(); + + const forceRefresh = controller.forceRefresh(); + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + + deferredNormalToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'stale' }), + }, + }), + ); + + await forceRefresh; + await flushPromises(); + + expect(performSignOut).not.toHaveBeenCalled(); + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('does not apply an in-flight forced token after a newer signed-in auth update arrives', async () => { + const deferredForcedToken = createDeferred(); + const deferredLatestToken = createDeferred(); + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'initial' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockReturnValueOnce(deferredForcedToken.promise); + getBearerToken.mockReturnValueOnce(deferredLatestToken.promise); + + const forceRefresh = controller.forceRefresh(); + await Promise.resolve(); + + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { latest: true } }, + }); + await Promise.resolve(); + + deferredForcedToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'stale-force' }), + }, + }), + ); + await Promise.resolve(); + + expect(controller.state.plan).toBe('initial'); + + deferredLatestToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'latest' }), + }, + }), + ); + + await forceRefresh; + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(3); + expect(controller.state.plan).toBe('latest'); + }); + + it('drops a stale queued force refresh without losing a newer normal hydration', async () => { + const deferredNormalToken = createDeferred(); + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockReturnValueOnce(deferredNormalToken.promise); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'latest' }), + }, + }), + ); + + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await Promise.resolve(); + + const forceRefresh = controller.forceRefresh(); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { changed: true } }, + }); + + deferredNormalToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'stale' }), + }, + }), + ); + + await forceRefresh; + await flushPromises(); + + expect(performSignOut).not.toHaveBeenCalled(); + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('latest'); + }); + + it('does not apply a stale token result after a later sign-out', async () => { + const deferredToken = createDeferred(); + const { controller, getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockReturnValueOnce(deferredToken.promise); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await Promise.resolve(); + + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + + deferredToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + + await flushPromises(); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('eventually hydrates the latest token if sign-in changes while an older hydration is in flight', async () => { + const deferredFirstToken = createDeferred(); + const { controller, getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockReturnValueOnce(deferredFirstToken.promise); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'latest' }), + }, + }), + ); + + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await Promise.resolve(); + + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { refreshed: true } }, + }); + + deferredFirstToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'stale' }), + }, + }), + ); + + await flushPromises(); + + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('latest'); + }); + + it('does not apply a forced token result if authentication signs out again before it resolves', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementation(() => { + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + }); + getBearerToken.mockImplementationOnce(async () => { + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { refreshed: true } }, + }); + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + + return createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'stale-force' }), + }, + }); + }); + + await controller.forceRefresh(); + await flushPromises(); + + expect(controller.state).toStrictEqual( + getDefaultMoneyAccountSubscriptionControllerState(), + ); + }); + + it('rejects a forced refresh failure after the forced sign-in is already observed', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementation(() => { + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + }); + getBearerToken.mockImplementationOnce(async () => { + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { refreshed: true } }, + }); + + throw new Error('forced refresh failed'); + }); + + await expect(controller.forceRefresh()).rejects.toThrow( + 'forced refresh failed', + ); + }); + + it('hydrates on a later normal sign-in after performSignOut fails during force refresh', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementationOnce(() => { + throw new Error('performSignOut failed'); + }); + + await expect(controller.forceRefresh()).rejects.toThrow( + 'performSignOut failed', + ); + + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'recovered' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { recovered: true } }, + }); + await flushPromises(); + + expect(controller.state.plan).toBe('recovered'); + }); + + it('hydrates on a later normal sign-in after getBearerToken fails during force refresh', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementationOnce(() => { + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + }); + getBearerToken.mockRejectedValueOnce(new Error('getBearerToken failed')); + + await expect(controller.forceRefresh()).rejects.toThrow( + 'getBearerToken failed', + ); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'recovered' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { recovered: true } }, + }); + await flushPromises(); + + expect(controller.state.plan).toBe('recovered'); + }); + + it('ignores non-object subscription lifecycle payloads', async () => { + const { getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + rootMessenger.publish( + 'SubscriptionController:stateChanged', + 'invalid-state' as never, + [] as never, + ); + await flushPromises(); + + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('does not force refresh on the first unrelated subscription event after bootstrap', async () => { + const { getBearerToken, performSignOut, rootMessenger } = createController({ + subscriptionState: { + subscriptions: [], + trialedProducts: [], + }, + }); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + + publishSubscriptionPayload(rootMessenger, { + subscriptions: [], + trialedProducts: [], + }); + await flushPromises(); + + expect(performSignOut).not.toHaveBeenCalled(); + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('forces refresh on the first meaningful subscription change after bootstrap', async () => { + const { getBearerToken, performSignOut, rootMessenger } = createController({ + subscriptionState: { + subscriptions: [], + trialedProducts: [], + }, + }); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'changed' }), + }, + }), + ); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + }); + + it('forces refresh on a later meaningful subscription change after bootstrap', async () => { + const { getBearerToken, performSignOut, rootMessenger } = createController({ + subscriptionState: createSubscriptionState(), + }); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).not.toHaveBeenCalled(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'later-change' }), + }, + }), + ); + publishSubscriptionState(rootMessenger, 'canceled'); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + }); + + it('ignores subscription lifecycle payloads without a subscriptions array', async () => { + const { getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + rootMessenger.publish( + 'SubscriptionController:stateChanged', + { subscriptions: 'invalid' } as never, + [] as never, + ); + await flushPromises(); + + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('treats irrelevant subscription lifecycle payloads as unchanged when baseline is already empty', async () => { + const { getBearerToken, performSignOut, rootMessenger } = createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + + rootMessenger.publish( + 'SubscriptionController:stateChanged', + { + subscriptions: [null], + } as never, + [] as never, + ); + await flushPromises(); + + expect(performSignOut).not.toHaveBeenCalled(); + expect(getBearerToken).not.toHaveBeenCalled(); + }); + + it('normalizes Money Account subscription snapshots with missing optional fields', async () => { + const { getBearerToken, performSignOut, rootMessenger } = createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValueOnce(createBearerToken({})); + + rootMessenger.publish( + 'SubscriptionController:stateChanged', + { + subscriptions: [ + { + products: [null, { name: MONEY_ACCOUNT_PLUS }], + }, + ], + } as never, + [] as never, + ); + await flushPromises(); + + expect(getBearerToken).toHaveBeenCalledTimes(1); + }); + + it('logs authentication refresh failures triggered by auth state changes', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + return undefined; + }); + const { getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockRejectedValueOnce(new Error('auth failed')); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + + errorSpy.mockRestore(); + }); + + it('logs bootstrap refresh failures consistently', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + return undefined; + }); + + createController({ + authenticationState: { + isSignedIn: true, + srpSessionData: { primary: {} }, + }, + getBearerTokenImplementation: async () => { + throw new Error('bootstrap failed'); + }, + }); + + await flushPromises(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + + errorSpy.mockRestore(); + }); + + it('forces a throttled refresh when Money Account Plus subscription state changes', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'subscription' }), + }, + }), + ); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + expect(controller.state.plan).toBe('subscription'); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + + jest.advanceTimersByTime(10_001); + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(getBearerToken).toHaveBeenCalledTimes(1); + }); + + it('does not throttle a new signed-in session after an external sign-out', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValue( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'session-a' }), + }, + }), + ); + await controller.forceRefresh(); + await flushPromises(); + + publishAuthenticationState(rootMessenger, { + isSignedIn: false, + }); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: { newSession: true } }, + }); + await flushPromises(); + + await controller.forceRefresh(); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(4); + }); + + it('logs subscription refresh failures triggered by subscription state changes', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + return undefined; + }); + const { getBearerToken, rootMessenger } = createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim(), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'steady' }), + }, + }), + ); + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(errorSpy).toHaveBeenCalledTimes(0); + + jest.advanceTimersByTime(10_001); + getBearerToken.mockRejectedValueOnce(new Error('subscription failed')); + publishSubscriptionState(rootMessenger, 'canceled'); + await flushPromises(); + + expect(errorSpy).toHaveBeenCalledTimes(1); + + errorSpy.mockRestore(); + }); +}); diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts new file mode 100644 index 00000000000..9b9fdcf0b6d --- /dev/null +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts @@ -0,0 +1,625 @@ +import type { + ControllerGetStateAction, + ControllerStateChangedEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import { StaticIntervalPollingController } from '@metamask/polling-controller'; + +import type { MoneyAccountSubscriptionControllerMethodActions } from './MoneyAccountSubscriptionController-method-action-types.js'; +import { getMoneyAccountPlusClaimFromBearerToken } from './jwt-claims.js'; +import type { + Env, + MoneyAccountPlusJwtClaim, + MoneyAccountSubscriptionControllerState, +} from './types.js'; +import { getProductEntitlementsClaimKey } from './types.js'; + +export const controllerName = 'MoneyAccountSubscriptionController'; + +export const DEFAULT_POLLING_INTERVAL_MS = 60_000; + +export const FORCE_REFRESH_THROTTLE_MS = 10_000; + +const POLLING_INPUT = { + type: 'entitlement-refresh', +} as const; + +type PollingInput = typeof POLLING_INPUT; + +export type MoneyAccountSubscriptionControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + MoneyAccountSubscriptionControllerState + >; + +export type MoneyAccountSubscriptionControllerActions = + | MoneyAccountSubscriptionControllerGetStateAction + | MoneyAccountSubscriptionControllerMethodActions; + +type AuthenticationControllerState = { + isSignedIn: boolean; +}; + +type AuthenticationControllerGetStateAction = { + type: 'AuthenticationController:getState'; + handler: () => AuthenticationControllerState; +}; + +type AuthenticationControllerGetBearerTokenAction = { + type: 'AuthenticationController:getBearerToken'; + handler: (entropySourceId?: string) => Promise; +}; + +type AuthenticationControllerPerformSignOutAction = { + type: 'AuthenticationController:performSignOut'; + handler: () => void; +}; + +type SubscriptionControllerGetStateAction = { + type: 'SubscriptionController:getState'; + handler: () => unknown; +}; + +type AllowedActions = + | AuthenticationControllerGetStateAction + | AuthenticationControllerGetBearerTokenAction + | AuthenticationControllerPerformSignOutAction + | SubscriptionControllerGetStateAction; + +type AuthenticationControllerStateChangedEvent = ControllerStateChangedEvent< + 'AuthenticationController', + AuthenticationControllerState +>; + +type SubscriptionControllerStateChangedEvent = { + type: 'SubscriptionController:stateChanged'; + payload: [unknown, unknown[]]; +}; + +export type MoneyAccountSubscriptionControllerStateChangedEvent = + ControllerStateChangedEvent< + typeof controllerName, + MoneyAccountSubscriptionControllerState + >; + +export type MoneyAccountSubscriptionControllerEvents = + MoneyAccountSubscriptionControllerStateChangedEvent; + +type AllowedEvents = + | AuthenticationControllerStateChangedEvent + | SubscriptionControllerStateChangedEvent; + +export type MoneyAccountSubscriptionControllerMessenger = Messenger< + typeof controllerName, + MoneyAccountSubscriptionControllerActions | AllowedActions, + MoneyAccountSubscriptionControllerEvents | AllowedEvents +>; + +export type MoneyAccountSubscriptionControllerOptions = { + messenger: MoneyAccountSubscriptionControllerMessenger; + state?: Partial; + env: Env; + pollingIntervalMs?: number; +}; + +const metadata: StateMetadata = { + plan: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + entitlements: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + isSubscriber: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, + lastHydratedAt: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: true, + usedInUi: true, + }, +}; + +const MESSENGER_EXPOSED_METHODS = [ + 'forceRefresh', + 'startEntitlementRefresh', + 'stopEntitlementRefresh', + 'clearState', +] as const; + +const MONEY_ACCOUNT_PLUS_PRODUCT = 'money_account_plus'; + +type RefreshMode = 'normal' | 'force'; + +function isPlainObject( + value: unknown, +): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +type SubscriptionStateSnapshot = { + subscriptions?: unknown; +}; + +type ProductSnapshot = { + name?: unknown; +}; + +type SubscriptionSnapshot = { + cancelType?: unknown; + currentPeriodEnd?: unknown; + currentPeriodStart?: unknown; + id?: unknown; + paymentMethod?: unknown; + products?: unknown; + status?: unknown; +}; + +export function getDefaultMoneyAccountSubscriptionControllerState(): MoneyAccountSubscriptionControllerState { + return { + plan: null, + entitlements: null, + isSubscriber: false, + lastHydratedAt: null, + }; +} + +export class MoneyAccountSubscriptionController extends StaticIntervalPollingController()< + typeof controllerName, + MoneyAccountSubscriptionControllerState, + MoneyAccountSubscriptionControllerMessenger +> { + readonly #claimKey: string; + + #entitlementRefreshPollingToken: string | null = null; + + #isSignedIn = false; + + #authStateRevision = 0; + + #refreshPromise: Promise | null = null; + + #activeRefreshMode: RefreshMode | null = null; + + #pendingHydrationAuthStateRevision: number | null = null; + + #pendingForceRefreshAuthStateRevision: number | null = null; + + #lastForcedRefreshAt: number | null = null; + + #expectedForcedRefreshSignIn = false; + + #observedForcedRefreshSignOut = false; + + #suppressedAuthHydrationRevision: number | null = null; + + #moneyAccountSubscriptionSnapshot: string | null | undefined; + + #isTradingSurfaceActive = false; + + constructor({ + messenger, + state, + env, + pollingIntervalMs = DEFAULT_POLLING_INTERVAL_MS, + }: MoneyAccountSubscriptionControllerOptions) { + super({ + messenger, + metadata, + name: controllerName, + state: { + ...getDefaultMoneyAccountSubscriptionControllerState(), + ...state, + }, + }); + + this.#claimKey = getProductEntitlementsClaimKey(env); + + this.setIntervalLength(pollingIntervalMs); + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + + this.messenger.subscribe( + 'AuthenticationController:stateChanged', + (stateChange) => { + this.#handleAuthenticationStateChange(stateChange).catch((error) => { + this.#logRefreshError(error); + }); + }, + ); + + this.messenger.subscribe('SubscriptionController:stateChanged', (stateChange) => { + this.#handleSubscriptionStateChange(stateChange).catch((error) => { + this.#logRefreshError(error); + }); + }); + + this.clearState(); + this.#bootstrap().catch((error) => { + this.#logRefreshError(error); + }); + } + + async forceRefresh(): Promise { + if (!this.#isSignedIn) { + return; + } + + const now = Date.now(); + if (this.#isForceRefreshThrottled(now)) { + if (this.#refreshPromise) { + await this.#refreshPromise; + } + return; + } + + await this.#enqueueRefresh('force'); + } + + startEntitlementRefresh(): void { + this.#isTradingSurfaceActive = true; + this.#syncPollingState(); + } + + stopEntitlementRefresh(): void { + this.#isTradingSurfaceActive = false; + this.#stopEntitlementRefreshPolling(); + } + + clearState(): void { + this.update(() => getDefaultMoneyAccountSubscriptionControllerState()); + this.#syncPollingState(); + } + + async _executePoll(): Promise { + if (!this.#isSignedIn || !this.state.isSubscriber) { + return; + } + + await this.#enqueueRefresh('force'); + } + + async #handleAuthenticationStateChange( + authState: AuthenticationControllerState, + ): Promise { + this.#authStateRevision += 1; + this.#isSignedIn = authState.isSignedIn; + + if (!authState.isSignedIn) { + this.#handleSignedOutAuthenticationState(); + return; + } + + if ( + this.#expectedForcedRefreshSignIn && + this.#observedForcedRefreshSignOut + ) { + this.#markForcedRefreshSignInObserved(); + return; + } + + await this.#hydrateSignedInAuthenticationState(); + } + + async #handleSubscriptionStateChange(subscriptionState: unknown): Promise { + if (!this.#isSignedIn) { + return; + } + + const nextSnapshot = + this.#getMoneyAccountSubscriptionSnapshot(subscriptionState); + + if (nextSnapshot === undefined) { + return; + } + + if (nextSnapshot === this.#moneyAccountSubscriptionSnapshot) { + return; + } + + this.#moneyAccountSubscriptionSnapshot = nextSnapshot; + await this.forceRefresh(); + } + + async #bootstrap(): Promise { + const authState = this.messenger.call('AuthenticationController:getState'); + this.#initializeSubscriptionSnapshot(); + await this.#handleAuthenticationStateChange(authState); + } + + async #enqueueRefresh(mode: RefreshMode): Promise { + this.#markRefreshPending(mode); + + if (!this.#refreshPromise) { + this.#refreshPromise = this.#drainRefreshQueue(); + } + + await this.#refreshPromise; + } + + async #drainRefreshQueue(): Promise { + try { + while ( + this.#pendingForceRefreshAuthStateRevision !== null || + this.#pendingHydrationAuthStateRevision !== null + ) { + const [mode, authStateRevision] = this.#takeNextRefresh(); + this.#activeRefreshMode = mode; + + if ( + authStateRevision === null || + !this.#shouldExecuteRefresh(authStateRevision) + ) { + continue; + } + + await this.#refreshBearerToken(mode, authStateRevision); + } + } finally { + this.#activeRefreshMode = null; + this.#refreshPromise = null; + } + } + + #shouldExecuteRefresh(authStateRevision: number): boolean { + return this.#isSignedIn && authStateRevision === this.#authStateRevision; + } + + async #refreshBearerToken( + mode: RefreshMode, + authStateRevision: number, + ): Promise { + if (mode === 'force') { + this.#lastForcedRefreshAt = Date.now(); + await this.#forceBearerTokenRefresh(authStateRevision); + return; + } + + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + + if (this.#shouldSkipNormalHydration(authStateRevision)) { + return; + } + + this.#applyClaimFromToken(token); + } + + async #forceBearerTokenRefresh(authStateRevision: number): Promise { + try { + this.#expectedForcedRefreshSignIn = true; + this.messenger.call('AuthenticationController:performSignOut'); + + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + const forcedSignInRevision = this.#suppressedAuthHydrationRevision; + + if ( + !this.#shouldApplyForcedRefreshToken( + forcedSignInRevision, + authStateRevision, + ) + ) { + return; + } + + this.#suppressedAuthHydrationRevision = null; + this.#applyClaimFromToken(token); + } catch (error) { + this.#lastForcedRefreshAt = null; + throw error; + } finally { + this.#resetForceRefreshCoordination(); + } + } + + #applyClaimFromToken(token: string): void { + const claim = getMoneyAccountPlusClaimFromBearerToken(token, this.#claimKey); + this.#applyClaim(claim, Date.now()); + } + + #isForceRefreshThrottled(now: number): boolean { + return ( + this.#lastForcedRefreshAt !== null && + now - this.#lastForcedRefreshAt < FORCE_REFRESH_THROTTLE_MS + ); + } + + #handleSignedOutAuthenticationState(): void { + if (this.#expectedForcedRefreshSignIn) { + this.#observedForcedRefreshSignOut = true; + } else { + this.#cancelPendingRefreshWork(); + this.#resetForceRefreshCoordination(); + this.#lastForcedRefreshAt = null; + } + + this.clearState(); + } + + #markForcedRefreshSignInObserved(): void { + this.#expectedForcedRefreshSignIn = false; + this.#observedForcedRefreshSignOut = false; + this.#suppressedAuthHydrationRevision = this.#authStateRevision; + } + + async #hydrateSignedInAuthenticationState(): Promise { + this.#suppressedAuthHydrationRevision = null; + this.#initializeSubscriptionSnapshot(); + await this.#enqueueRefresh('normal'); + } + + #markRefreshPending(mode: RefreshMode): void { + if (mode === 'normal') { + this.#pendingHydrationAuthStateRevision = this.#authStateRevision; + return; + } + + if (this.#activeRefreshMode !== 'force') { + this.#pendingForceRefreshAuthStateRevision = this.#authStateRevision; + } + } + + #takeNextRefresh(): [RefreshMode, number | null] { + if (this.#pendingForceRefreshAuthStateRevision === null) { + const authStateRevision = this.#pendingHydrationAuthStateRevision; + this.#pendingHydrationAuthStateRevision = null; + return ['normal', authStateRevision]; + } + + const authStateRevision = this.#pendingForceRefreshAuthStateRevision; + this.#pendingForceRefreshAuthStateRevision = null; + + return ['force', authStateRevision]; + } + + #shouldSkipNormalHydration(authStateRevision: number): boolean { + return ( + authStateRevision !== this.#authStateRevision || + !this.#isSignedIn || + authStateRevision === this.#suppressedAuthHydrationRevision + ); + } + + #shouldApplyForcedRefreshToken( + forcedSignInRevision: number | null, + authStateRevision: number, + ): boolean { + if (forcedSignInRevision === null) { + return this.#isSignedIn && this.#authStateRevision === authStateRevision; + } + + return ( + forcedSignInRevision === this.#authStateRevision && this.#isSignedIn + ); + } + + #initializeSubscriptionSnapshot(): void { + const subscriptionState = this.messenger.call('SubscriptionController:getState'); + this.#moneyAccountSubscriptionSnapshot = + this.#getMoneyAccountSubscriptionSnapshot(subscriptionState); + } + + #cancelPendingRefreshWork(): void { + this.#pendingHydrationAuthStateRevision = null; + this.#pendingForceRefreshAuthStateRevision = null; + } + + #resetForceRefreshCoordination(): void { + this.#expectedForcedRefreshSignIn = false; + this.#observedForcedRefreshSignOut = false; + this.#suppressedAuthHydrationRevision = null; + } + + #syncPollingState(): void { + if ( + this.#isTradingSurfaceActive && + this.#isSignedIn && + this.state.isSubscriber + ) { + if (!this.#entitlementRefreshPollingToken) { + this.#entitlementRefreshPollingToken = this.startPolling(POLLING_INPUT); + } + return; + } + + this.#stopEntitlementRefreshPolling(); + } + + #stopEntitlementRefreshPolling(): void { + if (!this.#entitlementRefreshPollingToken) { + return; + } + + this.stopPollingByPollingToken(this.#entitlementRefreshPollingToken); + this.#entitlementRefreshPollingToken = null; + } + + #getMoneyAccountSubscriptionSnapshot(state: unknown): string | null | undefined { + if (!isPlainObject(state)) { + return undefined; + } + + const { subscriptions } = state as SubscriptionStateSnapshot; + if (!Array.isArray(subscriptions)) { + return undefined; + } + + const relevantSubscriptions = subscriptions + .filter((subscription) => { + if (!isPlainObject(subscription)) { + return false; + } + + const { products } = subscription as SubscriptionSnapshot; + + return ( + Array.isArray(products) && + products.some((product) => { + const productSnapshot = product as ProductSnapshot; + return isPlainObject(product) + ? productSnapshot.name === MONEY_ACCOUNT_PLUS_PRODUCT + : false; + }) + ); + }) + .map((subscription) => { + const { + cancelType, + currentPeriodEnd, + currentPeriodStart, + id, + paymentMethod, + products, + status, + } = subscription as SubscriptionSnapshot; + + return { + cancelType: cancelType ?? null, + currentPeriodEnd: currentPeriodEnd ?? null, + currentPeriodStart: currentPeriodStart ?? null, + id: id ?? null, + paymentMethod: paymentMethod ?? null, + products, + status: status ?? null, + }; + }); + + if (relevantSubscriptions.length === 0) { + return null; + } + + return JSON.stringify(relevantSubscriptions); + } + + #logRefreshError(error: unknown): void { + console.error(error); + } + + #applyClaim( + claim: MoneyAccountPlusJwtClaim | null, + hydratedAt: number, + ): void { + this.update((state) => { + state.plan = claim?.plan ?? null; + state.entitlements = claim?.entitlements ?? null; + state.isSubscriber = claim !== null; + state.lastHydratedAt = hydratedAt; + }); + this.#syncPollingState(); + } +} diff --git a/packages/money-account-subscription-controller/src/index.test.ts b/packages/money-account-subscription-controller/src/index.test.ts deleted file mode 100644 index baa030ac10f..00000000000 --- a/packages/money-account-subscription-controller/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from './index.js'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); diff --git a/packages/money-account-subscription-controller/src/index.ts b/packages/money-account-subscription-controller/src/index.ts index 6972c117292..670cda964d6 100644 --- a/packages/money-account-subscription-controller/src/index.ts +++ b/packages/money-account-subscription-controller/src/index.ts @@ -1,9 +1,40 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export type { + MoneyAccountSubscriptionControllerActions, + MoneyAccountSubscriptionControllerEvents, + MoneyAccountSubscriptionControllerGetStateAction, + MoneyAccountSubscriptionControllerMessenger, + MoneyAccountSubscriptionControllerOptions, + MoneyAccountSubscriptionControllerStateChangedEvent, +} from './MoneyAccountSubscriptionController.js'; +export { + controllerName, + DEFAULT_POLLING_INTERVAL_MS, + FORCE_REFRESH_THROTTLE_MS, + getDefaultMoneyAccountSubscriptionControllerState, + MoneyAccountSubscriptionController, +} from './MoneyAccountSubscriptionController.js'; +export type { + MoneyAccountSubscriptionControllerClearStateAction, + MoneyAccountSubscriptionControllerForceRefreshAction, + MoneyAccountSubscriptionControllerStartEntitlementRefreshAction, + MoneyAccountSubscriptionControllerStopEntitlementRefreshAction, +} from './MoneyAccountSubscriptionController-method-action-types.js'; +export { + decodeJwtPayload, + getMoneyAccountPlusClaimFromBearerToken, +} from './jwt-claims.js'; +export { + selectHasEntitlement, + selectIsActiveSubscriber, + selectIsUsageAvailable, +} from './selectors.js'; +export type { + MoneyAccountEntitlements, + MoneyAccountPlusJwtClaim, + MoneyAccountProductEntitlementsJwtClaim, + MoneyAccountSubscriptionControllerState, +} from './types.js'; +export { + MoneyAccountFeature, + getProductEntitlementsClaimKey, +} from './types.js'; diff --git a/packages/money-account-subscription-controller/src/jwt-claims.test.ts b/packages/money-account-subscription-controller/src/jwt-claims.test.ts new file mode 100644 index 00000000000..78805846d79 --- /dev/null +++ b/packages/money-account-subscription-controller/src/jwt-claims.test.ts @@ -0,0 +1,122 @@ +import { + decodeJwtPayload, + getMoneyAccountPlusClaimFromBearerToken, +} from './jwt-claims.js'; +import { Env, getProductEntitlementsClaimKey } from './types.js'; + +function createBearerTokenFromRawPayload(rawPayload: string): string { + return [ + Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString( + 'base64url', + ), + Buffer.from(rawPayload).toString('base64url'), + 'signature', + ].join('.'); +} + +function createBearerToken(payload: unknown): string { + return createBearerTokenFromRawPayload(JSON.stringify(payload)); +} + +describe('jwt claims helpers', () => { + const claimKey = getProductEntitlementsClaimKey(Env.PRD); + + it('decodes a valid JWT payload', () => { + const payload = { foo: 'bar', answer: 42 }; + const token = createBearerToken(payload); + + expect(decodeJwtPayload(token)).toStrictEqual(payload); + }); + + it('decodes a payload that uses the base64url alphabet', () => { + const payload = { path: 'a/b+c', note: '>>>' }; + const token = createBearerToken(payload); + + expect(decodeJwtPayload(token)).toStrictEqual(payload); + }); + + it.each([ + '', + 'not-a-jwt', + 'header.payload', + 'header.a.signature', + 'header.***.signature', + 'header.invalid-base64.signature', + createBearerToken('not-an-object'), + createBearerTokenFromRawPayload('{'), + ])('returns null for a malformed JWT payload: %p', (token) => { + expect(decodeJwtPayload(token)).toBeNull(); + }); + + it('extracts the money account plus claim from the namespaced product entitlements claim', () => { + const claim = { + plan: 'premium', + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: false, + predictFreeTx: true, + premiumApy: false, + }, + }; + + const token = createBearerToken({ + [claimKey]: { + moneyAccountPlus: claim, + }, + }); + + expect(getMoneyAccountPlusClaimFromBearerToken(token, claimKey)).toStrictEqual( + claim, + ); + }); + + it.each([ + '', + 'not-a-jwt', + 'header.payload', + 'header.a.signature', + 'header.***.signature', + 'header.invalid-base64.signature', + createBearerToken('not-an-object'), + createBearerToken({}), + createBearerToken({ [claimKey]: null }), + createBearerToken({ [claimKey]: 'invalid' }), + createBearerToken({ [claimKey]: {} }), + createBearerToken({ [claimKey]: { moneyAccountPlus: null } }), + createBearerToken({ + [claimKey]: { + moneyAccountPlus: { + plan: 'premium', + entitlements: null, + }, + }, + }), + createBearerToken({ + [claimKey]: { + moneyAccountPlus: { + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: false, + predictFreeTx: true, + premiumApy: false, + }, + }, + }, + }), + createBearerToken({ + [claimKey]: { + moneyAccountPlus: { + plan: 'premium', + entitlements: { + swapFeeWaiver: true, + perpsFeeWaiver: 'nope', + predictFreeTx: true, + premiumApy: false, + }, + }, + }, + }), + ])('fails closed for malformed or unsupported claim payloads: %p', (token) => { + expect(getMoneyAccountPlusClaimFromBearerToken(token, claimKey)).toBeNull(); + }); +}); diff --git a/packages/money-account-subscription-controller/src/jwt-claims.ts b/packages/money-account-subscription-controller/src/jwt-claims.ts new file mode 100644 index 00000000000..198ee5753df --- /dev/null +++ b/packages/money-account-subscription-controller/src/jwt-claims.ts @@ -0,0 +1,98 @@ +import { + base64ToBytes, + bytesToString, + isPlainObject, +} from '@metamask/utils'; + +import type { + MoneyAccountEntitlements, + MoneyAccountPlusJwtClaim, + MoneyAccountProductEntitlementsJwtClaim, +} from './types.js'; + +function decodeBase64Url(value: string): string | null { + try { + return bytesToString( + base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ), + ); + } catch { + return null; + } +} + +function isMoneyAccountEntitlements( + value: unknown, +): value is MoneyAccountEntitlements { + if (!isPlainObject(value)) { + return false; + } + + return ( + typeof value.swapFeeWaiver === 'boolean' && + typeof value.perpsFeeWaiver === 'boolean' && + typeof value.predictFreeTx === 'boolean' && + typeof value.premiumApy === 'boolean' + ); +} + +function isMoneyAccountPlusJwtClaim( + value: unknown, +): value is MoneyAccountPlusJwtClaim { + if (!isPlainObject(value)) { + return false; + } + + return ( + typeof value.plan === 'string' && + isMoneyAccountEntitlements(value.entitlements) + ); +} + +function isMoneyAccountProductEntitlementsJwtClaim( + value: unknown, +): value is MoneyAccountProductEntitlementsJwtClaim { + return ( + isPlainObject(value) && isMoneyAccountPlusJwtClaim(value.moneyAccountPlus) + ); +} + +export function decodeJwtPayload(token: string): Record | null { + const parts = token.split('.'); + if (parts.length !== 3) { + return null; + } + + const decodedPayload = decodeBase64Url(parts[1]); + if (!decodedPayload) { + return null; + } + + try { + const payload = JSON.parse(decodedPayload); + return isPlainObject(payload) ? payload : null; + } catch { + return null; + } +} + +export function getMoneyAccountPlusClaimFromBearerToken( + token: string, + claimKey: string, +): MoneyAccountPlusJwtClaim | null { + const payload = decodeJwtPayload(token); + if (!payload) { + return null; + } + + const claim = payload[claimKey]; + if (!isMoneyAccountProductEntitlementsJwtClaim(claim)) { + return null; + } + + return claim.moneyAccountPlus; +} diff --git a/packages/money-account-subscription-controller/src/selectors.test.ts b/packages/money-account-subscription-controller/src/selectors.test.ts new file mode 100644 index 00000000000..5793748b667 --- /dev/null +++ b/packages/money-account-subscription-controller/src/selectors.test.ts @@ -0,0 +1,77 @@ +import { + selectHasEntitlement, + selectIsActiveSubscriber, + selectIsUsageAvailable, +} from './selectors.js'; +import { MoneyAccountFeature } from './types.js'; + +describe('money account subscription selectors', () => { + it('reports whether the current user is an active subscriber', () => { + expect( + selectIsActiveSubscriber({ + entitlements: null, + isSubscriber: true, + lastHydratedAt: null, + plan: null, + }), + ).toBe(true); + }); + + it('returns true only for enabled entitlements', () => { + const state = { + entitlements: { + perpsFeeWaiver: false, + predictFreeTx: true, + premiumApy: false, + swapFeeWaiver: true, + }, + isSubscriber: true, + lastHydratedAt: 1, + plan: 'premium', + }; + + expect( + selectHasEntitlement(state, MoneyAccountFeature.SwapFeeWaiver), + ).toBe(true); + expect( + selectHasEntitlement(state, MoneyAccountFeature.PerpsFeeWaiver), + ).toBe(false); + }); + + it('keeps usage availability aligned with entitlement booleans for now', () => { + const state = { + entitlements: { + perpsFeeWaiver: true, + predictFreeTx: false, + premiumApy: true, + swapFeeWaiver: false, + }, + isSubscriber: true, + lastHydratedAt: 1, + plan: 'premium', + }; + + expect( + selectIsUsageAvailable(state, MoneyAccountFeature.PerpsFeeWaiver), + ).toBe(true); + expect( + selectIsUsageAvailable(state, MoneyAccountFeature.SwapFeeWaiver), + ).toBe(false); + }); + + it('fails closed when entitlements are absent', () => { + const state = { + entitlements: null, + isSubscriber: false, + lastHydratedAt: null, + plan: null, + }; + + expect( + selectHasEntitlement(state, MoneyAccountFeature.PremiumApy), + ).toBe(false); + expect( + selectIsUsageAvailable(state, MoneyAccountFeature.PredictFreeTx), + ).toBe(false); + }); +}); diff --git a/packages/money-account-subscription-controller/src/selectors.ts b/packages/money-account-subscription-controller/src/selectors.ts new file mode 100644 index 00000000000..f97273ea221 --- /dev/null +++ b/packages/money-account-subscription-controller/src/selectors.ts @@ -0,0 +1,33 @@ +import type { + MoneyAccountFeature, + MoneyAccountSubscriptionControllerState, +} from './types.js'; + +export function selectIsActiveSubscriber( + state: MoneyAccountSubscriptionControllerState, +): boolean { + return state.isSubscriber; +} + +export function selectHasEntitlement( + state: MoneyAccountSubscriptionControllerState, + feature: MoneyAccountFeature, +): boolean { + return Boolean(state.entitlements?.[feature]); +} + +/** + * Usage availability currently matches entitlement because the server folds + * metered-cap exhaustion into the entitlement flag. Keeping this selector + * separate gives consumers a stable API if explicit usage claims are added. + * + * @param state - The Money Account subscription controller state. + * @param feature - The feature whose usage availability is queried. + * @returns Whether usage is currently available for the feature. + */ +export function selectIsUsageAvailable( + state: MoneyAccountSubscriptionControllerState, + feature: MoneyAccountFeature, +): boolean { + return selectHasEntitlement(state, feature); +} diff --git a/packages/money-account-subscription-controller/src/types.test.ts b/packages/money-account-subscription-controller/src/types.test.ts new file mode 100644 index 00000000000..a565883254d --- /dev/null +++ b/packages/money-account-subscription-controller/src/types.test.ts @@ -0,0 +1,34 @@ +import { + Env, + MoneyAccountFeature, + getProductEntitlementsClaimKey, +} from './types.js'; + +describe('money-account-subscription types', () => { + it('builds the product entitlements claim key from the subscription API URL', () => { + expect(getProductEntitlementsClaimKey(Env.DEV)).toBe( + 'https://subscription.dev-api.cx.metamask.io/productEntitlements', + ); + expect(getProductEntitlementsClaimKey(Env.UAT)).toBe( + 'https://subscription.uat-api.cx.metamask.io/productEntitlements', + ); + expect(getProductEntitlementsClaimKey(Env.PRD)).toBe( + 'https://subscription.api.cx.metamask.io/productEntitlements', + ); + }); + + it('exposes the supported Money Account Plus entitlement flags', () => { + expect(MoneyAccountFeature).toStrictEqual({ + SwapFeeWaiver: 'swapFeeWaiver', + PerpsFeeWaiver: 'perpsFeeWaiver', + PredictFreeTx: 'predictFreeTx', + PremiumApy: 'premiumApy', + }); + }); + + it('throws for an unsupported environment', () => { + expect(() => + getProductEntitlementsClaimKey('invalid' as Env), + ).toThrow('invalid environment configuration'); + }); +}); diff --git a/packages/money-account-subscription-controller/src/types.ts b/packages/money-account-subscription-controller/src/types.ts new file mode 100644 index 00000000000..dee9acf12b8 --- /dev/null +++ b/packages/money-account-subscription-controller/src/types.ts @@ -0,0 +1,56 @@ +export const Env = { + DEV: 'dev', + UAT: 'uat', + PRD: 'prd', +} as const; + +export type Env = (typeof Env)[keyof typeof Env]; + +export const MoneyAccountFeature = { + SwapFeeWaiver: 'swapFeeWaiver', + PerpsFeeWaiver: 'perpsFeeWaiver', + PredictFreeTx: 'predictFreeTx', + PremiumApy: 'premiumApy', +} as const; + +export type MoneyAccountFeature = + (typeof MoneyAccountFeature)[keyof typeof MoneyAccountFeature]; + +export type MoneyAccountEntitlements = { + swapFeeWaiver: boolean; + perpsFeeWaiver: boolean; + predictFreeTx: boolean; + premiumApy: boolean; +}; + +export type MoneyAccountPlusJwtClaim = { + plan: string; + entitlements: MoneyAccountEntitlements; +}; + +export type MoneyAccountProductEntitlementsJwtClaim = { + moneyAccountPlus: MoneyAccountPlusJwtClaim; +}; + +export type MoneyAccountSubscriptionControllerState = { + plan: string | null; + entitlements: MoneyAccountEntitlements | null; + isSubscriber: boolean; + lastHydratedAt: number | null; +}; + +const SUBSCRIPTION_API_URLS: Record = { + dev: 'https://subscription.dev-api.cx.metamask.io', + uat: 'https://subscription.uat-api.cx.metamask.io', + prd: 'https://subscription.api.cx.metamask.io', +}; + +export function getProductEntitlementsClaimKey(env: Env): string { + const subscriptionApiUrl = SUBSCRIPTION_API_URLS[env]?.replace(/\/v1$/u, ''); + + if (!subscriptionApiUrl) { + throw new Error('invalid environment configuration'); + } + + return `${subscriptionApiUrl}/productEntitlements`; +} diff --git a/packages/money-account-subscription-controller/tsconfig.build.json b/packages/money-account-subscription-controller/tsconfig.build.json index 02a0eea03fe..a1048de8542 100644 --- a/packages/money-account-subscription-controller/tsconfig.build.json +++ b/packages/money-account-subscription-controller/tsconfig.build.json @@ -5,6 +5,22 @@ "outDir": "./dist", "rootDir": "./src" }, - "references": [], + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../subscription-controller/tsconfig.build.json" + } + ], "include": ["../../types", "./src"] } diff --git a/packages/money-account-subscription-controller/tsconfig.json b/packages/money-account-subscription-controller/tsconfig.json index 025ba2ef7f4..7d05c33c68f 100644 --- a/packages/money-account-subscription-controller/tsconfig.json +++ b/packages/money-account-subscription-controller/tsconfig.json @@ -3,6 +3,22 @@ "compilerOptions": { "baseUrl": "./" }, - "references": [], + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../polling-controller/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller" + }, + { + "path": "../subscription-controller" + } + ], "include": ["../../types", "./src"] } diff --git a/teams.json b/teams.json index 3078fda3c48..7bcfc0d8476 100644 --- a/teams.json +++ b/teams.json @@ -65,6 +65,7 @@ "metamask/seedless-onboarding-controller": "team-onboarding", "metamask/shield-controller": "team-shield", "metamask/subscription-controller": "team-shield", + "metamask/money-account-subscription-controller": "team-shield", "metamask/claims-controller": "team-shield", "metamask/announcement-controller": "team-core-extension-ux,team-mobile-ux", "metamask/client-utils": "team-core-extension-ux,team-mobile-ux", diff --git a/yarn.lock b/yarn.lock index 9dae6298eea..bef720aa4e2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7914,6 +7914,12 @@ __metadata: resolution: "@metamask/money-account-subscription-controller@workspace:packages/money-account-subscription-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/polling-controller": "npm:^16.0.9" + "@metamask/profile-sync-controller": "npm:^29.0.0" + "@metamask/subscription-controller": "npm:^8.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" From 67679eeb4b1c43dfc40479501aa51d6dd1b00980 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 31 Aug 2026 15:14:47 +0700 Subject: [PATCH 3/8] fix: correct changelog --- packages/money-account-subscription-controller/CHANGELOG.md | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/money-account-subscription-controller/CHANGELOG.md b/packages/money-account-subscription-controller/CHANGELOG.md index 96167129ee0..dbecfda724a 100644 --- a/packages/money-account-subscription-controller/CHANGELOG.md +++ b/packages/money-account-subscription-controller/CHANGELOG.md @@ -9,10 +9,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `MoneyAccountSubscriptionController`, a read-only polling controller that hydrates Money Account Plus plan and entitlement flags from the Profile JWT, exposes gating selectors, and supports explicit and event-driven JWT refreshes. - -### Changed - -- Decode JWT payloads with `@metamask/utils` instead of `atob`. +- Add `MoneyAccountSubscriptionController`, a read-only polling controller that hydrates Money Account Plus plan and entitlement flags from the Profile JWT, exposes gating selectors, and supports explicit and event-driven JWT refreshes ([#10017](https://github.com/MetaMask/core/pull/10017)) [Unreleased]: https://github.com/MetaMask/core/ From 61ab29685f1838979dfd72a2c8d39460c444c066 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 31 Aug 2026 15:24:55 +0700 Subject: [PATCH 4/8] refactor(money-account-subscription-controller): update tsconfig paths and clean up unused types --- .../src/MoneyAccountSubscriptionController.ts | 42 +++++++------------ .../tsconfig.json | 4 +- 2 files changed, 16 insertions(+), 30 deletions(-) diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts index 9b9fdcf0b6d..bd00bbc9e3c 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts @@ -5,6 +5,16 @@ import type { } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; +import type { + AuthenticationControllerGetBearerTokenAction, + AuthenticationControllerGetStateAction, + AuthenticationControllerPerformSignOutAction, + AuthenticationControllerState, +} from '@metamask/profile-sync-controller/auth'; +import type { + SubscriptionControllerGetStateAction, + SubscriptionControllerState, +} from '@metamask/subscription-controller'; import type { MoneyAccountSubscriptionControllerMethodActions } from './MoneyAccountSubscriptionController-method-action-types.js'; import { getMoneyAccountPlusClaimFromBearerToken } from './jwt-claims.js'; @@ -37,30 +47,6 @@ export type MoneyAccountSubscriptionControllerActions = | MoneyAccountSubscriptionControllerGetStateAction | MoneyAccountSubscriptionControllerMethodActions; -type AuthenticationControllerState = { - isSignedIn: boolean; -}; - -type AuthenticationControllerGetStateAction = { - type: 'AuthenticationController:getState'; - handler: () => AuthenticationControllerState; -}; - -type AuthenticationControllerGetBearerTokenAction = { - type: 'AuthenticationController:getBearerToken'; - handler: (entropySourceId?: string) => Promise; -}; - -type AuthenticationControllerPerformSignOutAction = { - type: 'AuthenticationController:performSignOut'; - handler: () => void; -}; - -type SubscriptionControllerGetStateAction = { - type: 'SubscriptionController:getState'; - handler: () => unknown; -}; - type AllowedActions = | AuthenticationControllerGetStateAction | AuthenticationControllerGetBearerTokenAction @@ -72,10 +58,10 @@ type AuthenticationControllerStateChangedEvent = ControllerStateChangedEvent< AuthenticationControllerState >; -type SubscriptionControllerStateChangedEvent = { - type: 'SubscriptionController:stateChanged'; - payload: [unknown, unknown[]]; -}; +type SubscriptionControllerStateChangedEvent = ControllerStateChangedEvent< + 'SubscriptionController', + SubscriptionControllerState +>; export type MoneyAccountSubscriptionControllerStateChangedEvent = ControllerStateChangedEvent< diff --git a/packages/money-account-subscription-controller/tsconfig.json b/packages/money-account-subscription-controller/tsconfig.json index 7d05c33c68f..4fa325808eb 100644 --- a/packages/money-account-subscription-controller/tsconfig.json +++ b/packages/money-account-subscription-controller/tsconfig.json @@ -14,10 +14,10 @@ "path": "../polling-controller/tsconfig.build.json" }, { - "path": "../profile-sync-controller" + "path": "../profile-sync-controller/tsconfig.build.json" }, { - "path": "../subscription-controller" + "path": "../subscription-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] From cdcab177aa864caed5702663434a3e9cfd1e573a Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 31 Aug 2026 15:26:16 +0700 Subject: [PATCH 5/8] feat: update readme content --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 53266dad23f..ff4d9fb7f10 100644 --- a/README.md +++ b/README.md @@ -445,6 +445,11 @@ linkStyle default opacity:0.5 money_account_controller --> base_controller; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_subscription_controller --> base_controller; + money_account_subscription_controller --> messenger; + money_account_subscription_controller --> polling_controller; + money_account_subscription_controller --> profile_sync_controller; + money_account_subscription_controller --> subscription_controller; money_account_upgrade_controller --> authenticated_user_storage; money_account_upgrade_controller --> base_controller; money_account_upgrade_controller --> chomp_api_service; From 4691a6fb08bedb9baf63f1a6370650a5c434d536 Mon Sep 17 00:00:00 2001 From: Tuna Date: Mon, 31 Aug 2026 15:27:03 +0700 Subject: [PATCH 6/8] fix: lint --- ...MoneyAccountSubscriptionController.test.ts | 34 +++++++--------- .../src/MoneyAccountSubscriptionController.ts | 40 +++++++++++-------- .../src/jwt-claims.test.ts | 17 +++++--- .../src/jwt-claims.ts | 10 ++--- .../src/selectors.test.ts | 12 +++--- .../src/types.test.ts | 6 +-- 6 files changed, 62 insertions(+), 57 deletions(-) diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts index d520afd6827..bc62adf8c0a 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts @@ -15,10 +15,7 @@ import type { MoneyAccountSubscriptionControllerMessenger, MoneyAccountSubscriptionControllerState, } from './MoneyAccountSubscriptionController.js'; -import { - selectHasEntitlement, - selectIsActiveSubscriber, -} from './selectors.js'; +import { selectHasEntitlement, selectIsActiveSubscriber } from './selectors.js'; import { Env, getProductEntitlementsClaimKey, @@ -46,9 +43,7 @@ function createBearerToken(payload: unknown): string { ].join('.'); } -function createMoneyAccountClaim( - overrides = {}, -): { +function createMoneyAccountClaim(overrides = {}): { plan: string; entitlements: { swapFeeWaiver: boolean; @@ -69,9 +64,7 @@ function createMoneyAccountClaim( }; } -function createSubscriptionState( - status = ACTIVE_SUBSCRIPTION_STATUS, -): { +function createSubscriptionState(status = ACTIVE_SUBSCRIPTION_STATUS): { customerId: string; lastSubscription: undefined; pricing: undefined; @@ -220,10 +213,12 @@ function createController(options?: { return options?.authenticationState ?? { isSignedIn: false }; }); const getSubscriptionState = jest.fn(() => { - return options?.subscriptionState ?? { - subscriptions: [], - trialedProducts: [], - }; + return ( + options?.subscriptionState ?? { + subscriptions: [], + trialedProducts: [], + } + ); }); const getBearerToken = jest.fn, []>(); const performSignOut = jest.fn(); @@ -380,10 +375,7 @@ describe('MoneyAccountSubscriptionController', () => { ); expect(selectIsActiveSubscriber(controller.state)).toBe(false); expect( - selectHasEntitlement( - controller.state, - MoneyAccountFeature.SwapFeeWaiver, - ), + selectHasEntitlement(controller.state, MoneyAccountFeature.SwapFeeWaiver), ).toBe(false); await Promise.resolve(); @@ -1509,7 +1501,8 @@ describe('MoneyAccountSubscriptionController', () => { }); it('treats irrelevant subscription lifecycle payloads as unchanged when baseline is already empty', async () => { - const { getBearerToken, performSignOut, rootMessenger } = createController(); + const { getBearerToken, performSignOut, rootMessenger } = + createController(); getBearerToken.mockResolvedValueOnce( createBearerToken({ @@ -1541,7 +1534,8 @@ describe('MoneyAccountSubscriptionController', () => { }); it('normalizes Money Account subscription snapshots with missing optional fields', async () => { - const { getBearerToken, performSignOut, rootMessenger } = createController(); + const { getBearerToken, performSignOut, rootMessenger } = + createController(); getBearerToken.mockResolvedValueOnce( createBearerToken({ diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts index bd00bbc9e3c..2b3e8d7b143 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts @@ -16,8 +16,8 @@ import type { SubscriptionControllerState, } from '@metamask/subscription-controller'; -import type { MoneyAccountSubscriptionControllerMethodActions } from './MoneyAccountSubscriptionController-method-action-types.js'; import { getMoneyAccountPlusClaimFromBearerToken } from './jwt-claims.js'; +import type { MoneyAccountSubscriptionControllerMethodActions } from './MoneyAccountSubscriptionController-method-action-types.js'; import type { Env, MoneyAccountPlusJwtClaim, @@ -127,9 +127,7 @@ const MONEY_ACCOUNT_PLUS_PRODUCT = 'money_account_plus'; type RefreshMode = 'normal' | 'force'; -function isPlainObject( - value: unknown, -): value is Record { +function isPlainObject(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } @@ -226,11 +224,14 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon }, ); - this.messenger.subscribe('SubscriptionController:stateChanged', (stateChange) => { - this.#handleSubscriptionStateChange(stateChange).catch((error) => { - this.#logRefreshError(error); - }); - }); + this.messenger.subscribe( + 'SubscriptionController:stateChanged', + (stateChange) => { + this.#handleSubscriptionStateChange(stateChange).catch((error) => { + this.#logRefreshError(error); + }); + }, + ); this.clearState(); this.#bootstrap().catch((error) => { @@ -299,7 +300,9 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon await this.#hydrateSignedInAuthenticationState(); } - async #handleSubscriptionStateChange(subscriptionState: unknown): Promise { + async #handleSubscriptionStateChange( + subscriptionState: unknown, + ): Promise { if (!this.#isSignedIn) { return; } @@ -414,7 +417,10 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon } #applyClaimFromToken(token: string): void { - const claim = getMoneyAccountPlusClaimFromBearerToken(token, this.#claimKey); + const claim = getMoneyAccountPlusClaimFromBearerToken( + token, + this.#claimKey, + ); this.#applyClaim(claim, Date.now()); } @@ -489,13 +495,13 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon return this.#isSignedIn && this.#authStateRevision === authStateRevision; } - return ( - forcedSignInRevision === this.#authStateRevision && this.#isSignedIn - ); + return forcedSignInRevision === this.#authStateRevision && this.#isSignedIn; } #initializeSubscriptionSnapshot(): void { - const subscriptionState = this.messenger.call('SubscriptionController:getState'); + const subscriptionState = this.messenger.call( + 'SubscriptionController:getState', + ); this.#moneyAccountSubscriptionSnapshot = this.#getMoneyAccountSubscriptionSnapshot(subscriptionState); } @@ -535,7 +541,9 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon this.#entitlementRefreshPollingToken = null; } - #getMoneyAccountSubscriptionSnapshot(state: unknown): string | null | undefined { + #getMoneyAccountSubscriptionSnapshot( + state: unknown, + ): string | null | undefined { if (!isPlainObject(state)) { return undefined; } diff --git a/packages/money-account-subscription-controller/src/jwt-claims.test.ts b/packages/money-account-subscription-controller/src/jwt-claims.test.ts index 78805846d79..7a839e26adb 100644 --- a/packages/money-account-subscription-controller/src/jwt-claims.test.ts +++ b/packages/money-account-subscription-controller/src/jwt-claims.test.ts @@ -65,9 +65,9 @@ describe('jwt claims helpers', () => { }, }); - expect(getMoneyAccountPlusClaimFromBearerToken(token, claimKey)).toStrictEqual( - claim, - ); + expect( + getMoneyAccountPlusClaimFromBearerToken(token, claimKey), + ).toStrictEqual(claim); }); it.each([ @@ -116,7 +116,12 @@ describe('jwt claims helpers', () => { }, }, }), - ])('fails closed for malformed or unsupported claim payloads: %p', (token) => { - expect(getMoneyAccountPlusClaimFromBearerToken(token, claimKey)).toBeNull(); - }); + ])( + 'fails closed for malformed or unsupported claim payloads: %p', + (token) => { + expect( + getMoneyAccountPlusClaimFromBearerToken(token, claimKey), + ).toBeNull(); + }, + ); }); diff --git a/packages/money-account-subscription-controller/src/jwt-claims.ts b/packages/money-account-subscription-controller/src/jwt-claims.ts index 198ee5753df..2251af002ed 100644 --- a/packages/money-account-subscription-controller/src/jwt-claims.ts +++ b/packages/money-account-subscription-controller/src/jwt-claims.ts @@ -1,8 +1,4 @@ -import { - base64ToBytes, - bytesToString, - isPlainObject, -} from '@metamask/utils'; +import { base64ToBytes, bytesToString, isPlainObject } from '@metamask/utils'; import type { MoneyAccountEntitlements, @@ -61,7 +57,9 @@ function isMoneyAccountProductEntitlementsJwtClaim( ); } -export function decodeJwtPayload(token: string): Record | null { +export function decodeJwtPayload( + token: string, +): Record | null { const parts = token.split('.'); if (parts.length !== 3) { return null; diff --git a/packages/money-account-subscription-controller/src/selectors.test.ts b/packages/money-account-subscription-controller/src/selectors.test.ts index 5793748b667..ecababd9649 100644 --- a/packages/money-account-subscription-controller/src/selectors.test.ts +++ b/packages/money-account-subscription-controller/src/selectors.test.ts @@ -30,9 +30,9 @@ describe('money account subscription selectors', () => { plan: 'premium', }; - expect( - selectHasEntitlement(state, MoneyAccountFeature.SwapFeeWaiver), - ).toBe(true); + expect(selectHasEntitlement(state, MoneyAccountFeature.SwapFeeWaiver)).toBe( + true, + ); expect( selectHasEntitlement(state, MoneyAccountFeature.PerpsFeeWaiver), ).toBe(false); @@ -67,9 +67,9 @@ describe('money account subscription selectors', () => { plan: null, }; - expect( - selectHasEntitlement(state, MoneyAccountFeature.PremiumApy), - ).toBe(false); + expect(selectHasEntitlement(state, MoneyAccountFeature.PremiumApy)).toBe( + false, + ); expect( selectIsUsageAvailable(state, MoneyAccountFeature.PredictFreeTx), ).toBe(false); diff --git a/packages/money-account-subscription-controller/src/types.test.ts b/packages/money-account-subscription-controller/src/types.test.ts index a565883254d..c991671e66a 100644 --- a/packages/money-account-subscription-controller/src/types.test.ts +++ b/packages/money-account-subscription-controller/src/types.test.ts @@ -27,8 +27,8 @@ describe('money-account-subscription types', () => { }); it('throws for an unsupported environment', () => { - expect(() => - getProductEntitlementsClaimKey('invalid' as Env), - ).toThrow('invalid environment configuration'); + expect(() => getProductEntitlementsClaimKey('invalid' as Env)).toThrow( + 'invalid environment configuration', + ); }); }); From a8f62f3adb742f795220f535dee5e9d05a9a69b6 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 1 Sep 2026 08:40:24 +0700 Subject: [PATCH 7/8] feat(money-account-subscription-controller): enhance JWT refresh logic during subscription changes --- ...MoneyAccountSubscriptionController.test.ts | 116 ++++++++++++++++++ .../src/MoneyAccountSubscriptionController.ts | 13 +- 2 files changed, 124 insertions(+), 5 deletions(-) diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts index bc62adf8c0a..cb6c988935f 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts @@ -1660,6 +1660,122 @@ describe('MoneyAccountSubscriptionController', () => { expect(getBearerToken).toHaveBeenCalledTimes(1); }); + it('refreshes JWT when a distinct subscription snapshot arrives while force refresh is throttled', async () => { + jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); + jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); + + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'initial' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'forced' }), + }, + }), + ); + + await controller.forceRefresh(); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + expect(controller.state.plan).toBe('forced'); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'subscribed' }), + }, + }), + ); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('subscribed'); + + publishSubscriptionState(rootMessenger); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + }); + + it('refreshes JWT again when a subscription change arrives during an in-flight force refresh', async () => { + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'initial' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + const deferredToken = createDeferred(); + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockReturnValueOnce(deferredToken.promise); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'canceled' }), + }, + }), + ); + + const firstRefresh = controller.forceRefresh(); + await Promise.resolve(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + + publishSubscriptionState(rootMessenger, 'canceled'); + + deferredToken.resolve( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'in-flight' }), + }, + }), + ); + + await firstRefresh; + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('canceled'); + + publishSubscriptionState(rootMessenger, 'canceled'); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + }); + it('does not throttle a new signed-in session after an external sign-out', async () => { jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts index 2b3e8d7b143..934761e2a0d 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts @@ -319,7 +319,7 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon } this.#moneyAccountSubscriptionSnapshot = nextSnapshot; - await this.forceRefresh(); + await this.#enqueueRefresh('force', { queueFollowUpForce: true }); } async #bootstrap(): Promise { @@ -328,8 +328,11 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon await this.#handleAuthenticationStateChange(authState); } - async #enqueueRefresh(mode: RefreshMode): Promise { - this.#markRefreshPending(mode); + async #enqueueRefresh( + mode: RefreshMode, + { queueFollowUpForce = false }: { queueFollowUpForce?: boolean } = {}, + ): Promise { + this.#markRefreshPending(mode, queueFollowUpForce); if (!this.#refreshPromise) { this.#refreshPromise = this.#drainRefreshQueue(); @@ -455,13 +458,13 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon await this.#enqueueRefresh('normal'); } - #markRefreshPending(mode: RefreshMode): void { + #markRefreshPending(mode: RefreshMode, queueFollowUpForce: boolean): void { if (mode === 'normal') { this.#pendingHydrationAuthStateRevision = this.#authStateRevision; return; } - if (this.#activeRefreshMode !== 'force') { + if (this.#activeRefreshMode !== 'force' || queueFollowUpForce) { this.#pendingForceRefreshAuthStateRevision = this.#authStateRevision; } } From 1488fc272ca6e20b4f3842bf829f36ee657e9e18 Mon Sep 17 00:00:00 2001 From: Tuna Date: Tue, 1 Sep 2026 09:01:54 +0700 Subject: [PATCH 8/8] feat(money-account-subscription-controller): improve error handling in refresh queue and add test for force refresh failure --- ...MoneyAccountSubscriptionController.test.ts | 63 ++++++++++++++++++- .../src/MoneyAccountSubscriptionController.ts | 32 +++++++++- 2 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts index cb6c988935f..abcbcad5516 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.test.ts @@ -267,16 +267,22 @@ function createController(options?: { function createDeferred(): { promise: Promise; resolve: (value: DeferredValue) => void; + reject: (error: Error) => void; } { let resolve: (value: DeferredValue) => void = () => undefined; + let reject: (error: Error) => void = () => undefined; - const promise = new Promise((resolvePromise) => { - resolve = resolvePromise; - }); + const promise = new Promise( + (resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }, + ); return { promise, resolve, + reject, }; } @@ -1776,6 +1782,57 @@ describe('MoneyAccountSubscriptionController', () => { expect(getBearerToken).toHaveBeenCalledTimes(2); }); + it('runs a queued follow-up force refresh even when the in-flight force refresh fails', async () => { + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => { + return undefined; + }); + const { controller, getBearerToken, performSignOut, rootMessenger } = + createController(); + + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'initial' }), + }, + }), + ); + publishAuthenticationState(rootMessenger, { + isSignedIn: true, + srpSessionData: { primary: {} }, + }); + await flushPromises(); + + const deferredToken = createDeferred(); + getBearerToken.mockClear(); + performSignOut.mockImplementation(() => undefined); + getBearerToken.mockReturnValueOnce(deferredToken.promise); + getBearerToken.mockResolvedValueOnce( + createBearerToken({ + [claimKey]: { + moneyAccountPlus: createMoneyAccountClaim({ plan: 'recovered' }), + }, + }), + ); + + const firstRefresh = controller.forceRefresh(); + await Promise.resolve(); + + expect(performSignOut).toHaveBeenCalledTimes(1); + + publishSubscriptionState(rootMessenger, 'canceled'); + + deferredToken.reject(new Error('force refresh failed')); + + await expect(firstRefresh).rejects.toThrow('force refresh failed'); + await flushPromises(); + + expect(performSignOut).toHaveBeenCalledTimes(2); + expect(getBearerToken).toHaveBeenCalledTimes(2); + expect(controller.state.plan).toBe('recovered'); + + errorSpy.mockRestore(); + }); + it('does not throttle a new signed-in session after an external sign-out', async () => { jest.useFakeTimers({ doNotFake: ['nextTick', 'queueMicrotask'] }); jest.setSystemTime(new Date('2026-01-01T00:00:00.000Z')); diff --git a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts index 934761e2a0d..671faa80fe9 100644 --- a/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts +++ b/packages/money-account-subscription-controller/src/MoneyAccountSubscriptionController.ts @@ -171,6 +171,21 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon #authStateRevision = 0; + // Refresh queue: refreshes run one at a time through `#drainRefreshQueue`, + // because a 'force' refresh signs out and back in and must not overlap + // with another refresh. + // + // - `#refreshPromise` is the running drain loop (null when idle); every + // enqueuer awaits the same promise. + // - The two `#pending*` slots hold at most one request per mode, tagged + // with the auth revision at enqueue time. Re-enqueueing overwrites the + // slot, so bursts collapse into the latest request. The loop skips + // requests whose auth revision is stale and runs 'force' before 'normal'. + // - A force request arriving during a running force refresh is normally + // absorbed by it; `queueFollowUpForce` queues another run instead + // (subscription changes need this, having already committed their + // snapshot). + // - If a request fails, the loop still drains the rest, then rethrows. #refreshPromise: Promise | null = null; #activeRefreshMode: RefreshMode | null = null; @@ -342,6 +357,9 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon } async #drainRefreshQueue(): Promise { + let queueError: unknown; + let hasQueueError = false; + try { while ( this.#pendingForceRefreshAuthStateRevision !== null || @@ -357,12 +375,24 @@ export class MoneyAccountSubscriptionController extends StaticIntervalPollingCon continue; } - await this.#refreshBearerToken(mode, authStateRevision); + try { + await this.#refreshBearerToken(mode, authStateRevision); + } catch (error) { + // Keep draining so queued work (e.g. a follow-up force refresh + // queued by a subscription change whose snapshot is already + // committed) is not stranded by a transient failure. + queueError = error; + hasQueueError = true; + } } } finally { this.#activeRefreshMode = null; this.#refreshPromise = null; } + + if (hasQueueError) { + throw queueError; + } } #shouldExecuteRefresh(authStateRevision: number): boolean {