From 1166c0c1c845dc6a4c6672f7b8a93c0a1195cd9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 00:00:35 +0200 Subject: [PATCH 01/67] feat: init KYC controller and add logic from mobile repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- README.md | 7 + packages/kyc-controller/CHANGELOG.md | 18 + packages/kyc-controller/LICENSE | 6 + packages/kyc-controller/LICENSE.APACHE2 | 201 ++ packages/kyc-controller/LICENSE.MIT | 21 + packages/kyc-controller/README.md | 23 + packages/kyc-controller/jest.config.js | 26 + packages/kyc-controller/package.json | 89 + .../src/KycController-method-action-types.ts | 160 + .../kyc-controller/src/KycController.test.ts | 814 +++++ packages/kyc-controller/src/KycController.ts | 855 +++++ .../src/KycService-method-action-types.ts | 87 + .../kyc-controller/src/KycService.test.ts | 352 ++ packages/kyc-controller/src/KycService.ts | 418 +++ .../kyc-controller/src/countryCodes.test.ts | 19 + packages/kyc-controller/src/countryCodes.ts | 270 ++ packages/kyc-controller/src/crypto.test.ts | 204 ++ packages/kyc-controller/src/crypto.ts | 252 ++ packages/kyc-controller/src/index.test.ts | 19 + packages/kyc-controller/src/index.ts | 76 + packages/kyc-controller/src/selectors.test.ts | 33 + packages/kyc-controller/src/selectors.ts | 42 + packages/kyc-controller/src/types.ts | 119 + packages/kyc-controller/tsconfig.build.json | 16 + packages/kyc-controller/tsconfig.json | 14 + packages/kyc-controller/typedoc.json | 7 + tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 3003 +++++++++-------- 29 files changed, 5695 insertions(+), 1462 deletions(-) create mode 100644 packages/kyc-controller/CHANGELOG.md create mode 100644 packages/kyc-controller/LICENSE create mode 100644 packages/kyc-controller/LICENSE.APACHE2 create mode 100644 packages/kyc-controller/LICENSE.MIT create mode 100644 packages/kyc-controller/README.md create mode 100644 packages/kyc-controller/jest.config.js create mode 100644 packages/kyc-controller/package.json create mode 100644 packages/kyc-controller/src/KycController-method-action-types.ts create mode 100644 packages/kyc-controller/src/KycController.test.ts create mode 100644 packages/kyc-controller/src/KycController.ts create mode 100644 packages/kyc-controller/src/KycService-method-action-types.ts create mode 100644 packages/kyc-controller/src/KycService.test.ts create mode 100644 packages/kyc-controller/src/KycService.ts create mode 100644 packages/kyc-controller/src/countryCodes.test.ts create mode 100644 packages/kyc-controller/src/countryCodes.ts create mode 100644 packages/kyc-controller/src/crypto.test.ts create mode 100644 packages/kyc-controller/src/crypto.ts create mode 100644 packages/kyc-controller/src/index.test.ts create mode 100644 packages/kyc-controller/src/index.ts create mode 100644 packages/kyc-controller/src/selectors.test.ts create mode 100644 packages/kyc-controller/src/selectors.ts create mode 100644 packages/kyc-controller/src/types.ts create mode 100644 packages/kyc-controller/tsconfig.build.json create mode 100644 packages/kyc-controller/tsconfig.json create mode 100644 packages/kyc-controller/typedoc.json diff --git a/README.md b/README.md index 24c9ede1fed..ea6739a6593 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/json-rpc-engine`](packages/json-rpc-engine) - [`@metamask/json-rpc-middleware-stream`](packages/json-rpc-middleware-stream) - [`@metamask/keyring-controller`](packages/keyring-controller) +- [`@metamask/kyc-controller`](packages/kyc-controller) - [`@metamask/local-node-utils`](packages/local-node-utils) - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) @@ -191,6 +192,7 @@ linkStyle default opacity:0.5 json_rpc_engine(["@metamask/json-rpc-engine"]); json_rpc_middleware_stream(["@metamask/json-rpc-middleware-stream"]); keyring_controller(["@metamask/keyring-controller"]); + kyc_controller(["@metamask/kyc-controller"]); local_node_utils(["@metamask/local-node-utils"]); logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); @@ -418,6 +420,11 @@ linkStyle default opacity:0.5 keyring_controller --> base_controller; keyring_controller --> controller_utils; keyring_controller --> messenger; + kyc_controller --> base_controller; + kyc_controller --> controller_utils; + kyc_controller --> geolocation_controller; + kyc_controller --> messenger; + kyc_controller --> profile_sync_controller; logging_controller --> base_controller; logging_controller --> controller_utils; logging_controller --> messenger; diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md new file mode 100644 index 00000000000..90c4dac4c9c --- /dev/null +++ b/packages/kyc-controller/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients ([#0000](https://github.com/MetaMask/core/pull/0000)) + - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. + - `KycService` performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. + - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/LICENSE b/packages/kyc-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/kyc-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/kyc-controller/LICENSE.APACHE2 b/packages/kyc-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..e6e77b08909 --- /dev/null +++ b/packages/kyc-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/kyc-controller/LICENSE.MIT b/packages/kyc-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/kyc-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/kyc-controller/README.md b/packages/kyc-controller/README.md new file mode 100644 index 00000000000..5cc5ccfcb25 --- /dev/null +++ b/packages/kyc-controller/README.md @@ -0,0 +1,23 @@ +# `@metamask/kyc-controller` + +Shared KYC / identity verification controller used across MetaMask clients + +## Installation + +`yarn add @metamask/kyc-controller` + +or + +`npm install @metamask/kyc-controller` + +## Development + +To rebuild the package automatically whenever you change a source file, run the `build:watch` script: + +`yarn workspace @metamask/kyc-controller run build:watch` + +This watches `src/**/*.ts` and re-runs the build on each change (it also performs an initial build on start), which is useful when developing against a client that consumes this package locally. + +## 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/kyc-controller/jest.config.js b/packages/kyc-controller/jest.config.js new file mode 100644 index 00000000000..ca084133399 --- /dev/null +++ b/packages/kyc-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/kyc-controller/package.json b/packages/kyc-controller/package.json new file mode 100644 index 00000000000..2c5b3b1d55d --- /dev/null +++ b/packages/kyc-controller/package.json @@ -0,0 +1,89 @@ +{ + "name": "@metamask/kyc-controller", + "version": "0.0.0", + "description": "Shared KYC / identity verification controller used across MetaMask clients", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/kyc-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", + "build:watch": "chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", + "changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/geolocation-controller": "^0.1.3", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^28.3.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "chokidar-cli": "^3.0.0", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts new file mode 100644 index 00000000000..a8610c695ad --- /dev/null +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -0,0 +1,160 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycController } from './KycController'; + +/** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + */ +export type KycControllerInitializeAction = { + type: `KycController:initialize`; + handler: KycController['initialize']; +}; + +/** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ +export type KycControllerLoadDisclaimersAction = { + type: `KycController:loadDisclaimers`; + handler: KycController['loadDisclaimers']; +}; + +/** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + */ +export type KycControllerAcceptTermsAndStartSessionAction = { + type: `KycController:acceptTermsAndStartSession`; + handler: KycController['acceptTermsAndStartSession']; +}; + +/** + * Clears the persisted terms acceptance. + */ +export type KycControllerClearSavedTermsAction = { + type: `KycController:clearSavedTerms`; + handler: KycController['clearSavedTerms']; +}; + +/** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ +export type KycControllerHandleFrameMessageAction = { + type: `KycController:handleFrameMessage`; + handler: KycController['handleFrameMessage']; +}; + +/** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ +export type KycControllerBuildCheckFrameUrlAction = { + type: `KycController:buildCheckFrameUrl`; + handler: KycController['buildCheckFrameUrl']; +}; + +/** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ +export type KycControllerBuildAuthFrameUrlAction = { + type: `KycController:buildAuthFrameUrl`; + handler: KycController['buildAuthFrameUrl']; +}; + +/** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ +export type KycControllerBuildResetFrameUrlAction = { + type: `KycController:buildResetFrameUrl`; + handler: KycController['buildResetFrameUrl']; +}; + +/** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ +export type KycControllerCheckKycRequiredAction = { + type: `KycController:checkKycRequired`; + handler: KycController['checkKycRequired']; +}; + +/** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ +export type KycControllerGetKycStatusAction = { + type: `KycController:getKycStatus`; + handler: KycController['getKycStatus']; +}; + +/** + * Runs the SumSub document-verification sub-flow: creates a UKYC session, + * exchanges the wrapped key for an applicant access token, and presents the + * SDK via the injected launcher. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ +export type KycControllerStartSumSubAction = { + type: `KycController:startSumSub`; + handler: KycController['startSumSub']; +}; + +/** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ +export type KycControllerResetAction = { + type: `KycController:reset`; + handler: KycController['reset']; +}; + +/** + * Union of all KycController action types. + */ +export type KycControllerMethodActions = + | KycControllerInitializeAction + | KycControllerLoadDisclaimersAction + | KycControllerAcceptTermsAndStartSessionAction + | KycControllerClearSavedTermsAction + | KycControllerHandleFrameMessageAction + | KycControllerBuildCheckFrameUrlAction + | KycControllerBuildAuthFrameUrlAction + | KycControllerBuildResetFrameUrlAction + | KycControllerCheckKycRequiredAction + | KycControllerGetKycStatusAction + | KycControllerStartSumSubAction + | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts new file mode 100644 index 00000000000..5ab54f75aed --- /dev/null +++ b/packages/kyc-controller/src/KycController.test.ts @@ -0,0 +1,814 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; + +import { KycController } from './KycController'; +import type { KycControllerMessenger } from './KycController'; +import type { KycSumSubLauncher } from './types'; + +/** + * Builds an encrypted envelope for a recipient's X25519 public key. + * + * @param publicKey - The recipient's public key bytes. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function makeEnvelope( + publicKey: Uint8Array, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(12).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + return { + ephemeralPublicKey: bytesToHex(ephemeralPublic), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }; +} + +/** + * Extracts the controller's ephemeral public key from the Check-frame URL and + * builds a decryptable credentials envelope for it. + * + * @param controller - The controller under test (must have a session token). + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function envelopeFor( + controller: KycController, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const url = controller.buildCheckFrameUrl(); + const publicKeyHex = new URL(url as string).searchParams.get( + 'publicKey', + ) as string; + return makeEnvelope(hexToBytes(publicKeyHex), credentials); +} + +describe('KycController', () => { + describe('constructor', () => { + it('accepts initial state merged over defaults', async () => { + await withController( + { options: { state: { phase: 'form' } } }, + ({ controller }) => { + expect(controller.state.phase).toBe('form'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + }); + + describe('initialize', () => { + it('auto-creates a session when terms and email are present', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.geoCountry).toBe('USA'); + expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockRejectedValue(new Error('geo down')); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + }); + }); + + it('stays on terms when terms exist but no email is available', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + }); + + describe('loadDisclaimers', () => { + it('loads disclaimers for a provided country', async () => { + await withController(async ({ controller, handlers }) => { + const disclaimers = [{ id: '1', display_name: 'T', url: 'u' }]; + handlers.fetchDisclaimers.mockResolvedValue(disclaimers); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + }); + }); + + it('uses the cached geoCountry when no country is provided', async () => { + await withController( + { options: { state: { geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'USA', + }); + }, + ); + }); + + it('resolves the country when neither param nor cache is available', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('FRA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(controller.state.geoCountry).toBe('FRA'); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'FRA', + }); + }); + }); + + it('records an error when loading fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockRejectedValue(new Error('boom')); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimersError).toMatch(/boom/u); + }); + }); + }); + + describe('acceptTermsAndStartSession', () => { + it('captures terms and creates a session', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('reverts to terms when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Session creation failed/u); + }, + ); + }); + + it('fails when no email is available', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails when no disclaimers were accepted', async () => { + await withController(async ({ controller }) => { + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing terms acceptance/u); + }); + }); + }); + + describe('clearSavedTerms', () => { + it('clears persisted terms', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + ({ controller }) => { + controller.clearSavedTerms(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + }, + ); + }); + }); + + describe('handleFrameMessage', () => { + it('acks a handshake', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { kind: 'handshake', meta: { channelId: 'ch_1' } }, + }); + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, + }); + }); + }); + + it('ignores undefined and non-complete messages', async () => { + await withController(async ({ controller }) => { + expect( + await controller.handleFrameMessage({ message: undefined }), + ).toStrictEqual({}); + expect( + await controller.handleFrameMessage({ message: { kind: 'other' } }), + ).toStrictEqual({}); + }); + }); + + it('captures the customer id and ignores a status-less complete message', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }); + }); + + it('ignores messages on an unknown channel', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_unknown' }, + payload: { status: 'active' }, + }, + }); + expect(result).toStrictEqual({}); + }); + }); + + it('fails when credential decryption throws', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: 'not-decryptable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Failed to decrypt/u); + }, + ); + }); + + describe('check frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-1'); + }, + ); + }); + + it('moves to auth on connectionRequired and enables the auth frame URL', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + clientToken: 'client-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelope, + }, + }, + }); + expect(controller.state.phase).toBe('auth'); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=client-1', + ); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'failed' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + + describe('auth frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-2', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-2'); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController(async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + }); + }); + + it('fails on an unexpected status', async () => { + await withController(async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }); + }); + }); + }); + + describe('frame URL builders', () => { + it('returns null for the check frame without a session', async () => { + await withController(({ controller }) => { + expect(controller.buildCheckFrameUrl()).toBeNull(); + }); + }); + + it('builds the check frame URL with a session', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + ({ controller }) => { + const url = controller.buildCheckFrameUrl() as string; + expect(url).toContain('sessionToken=tok'); + expect(url).toContain('channelId=ch_1'); + expect(url).toContain('skipKyc=true'); + }, + ); + }); + + it('returns null for the auth frame without a client token', async () => { + await withController(({ controller }) => { + expect(controller.buildAuthFrameUrl()).toBeNull(); + }); + }); + + it('builds the reset frame URL', async () => { + await withController(({ controller }) => { + expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + }); + }); + }); + + describe('checkKycRequired', () => { + it('fails without an access token', async () => { + await withController(async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing accessToken/u); + }); + }); + + it('fails without a country', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing country/u); + }, + ); + }); + + it('caches the result on success (cached country)', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + true, + ); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + expect(controller.state.phase).toBe('done'); + }, + ); + }); + + it('accepts a country override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + + await controller.checkKycRequired({ + product: 'card', + country: 'FRA', + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'FRA', + capabilities: [{ product: 'card' }], + }); + }, + ); + }); + + it('fails when the service throws', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/KYC check failed/u); + }, + ); + }); + }); + + describe('getKycStatus', () => { + it('returns the cached value or undefined', async () => { + await withController( + { options: { state: { kycRequiredByProduct: { ramps: true } } } }, + ({ controller }) => { + expect(controller.getKycStatus({ product: 'ramps' })).toBe(true); + expect(controller.getKycStatus({ product: 'card' })).toBeUndefined(); + }, + ); + }); + }); + + describe('startSumSub', () => { + it('throws and marks failed when the SDK is unavailable', async () => { + await withController(async ({ controller, launcher }) => { + launcher.isAvailable.mockReturnValue(false); + + await expect(controller.startSumSub()).rejects.toThrow( + /not available/u, + ); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('runs the full sub-flow and completes', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }); + handlers.submitWrappedKey.mockResolvedValue({ + status: 'ok', + applicantAccessToken: 'aat', + }); + launcher.launch.mockImplementation( + async ({ onStatusChange, onTokenExpiration }) => { + onStatusChange?.('idle', 'InProgress'); + onStatusChange?.('InProgress', 'Completed'); + await onTokenExpiration(); + return { ok: true }; + }, + ); + + const result = await controller.startSumSub({ + locale: 'fr', + debug: true, + }); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); + // onTokenExpiration re-invokes the exchange. + expect(handlers.submitWrappedKey).toHaveBeenCalledTimes(2); + }); + }); + + it('defaults locale and debug when no params are given', async () => { + await withController(async ({ controller, launcher }) => { + await controller.startSumSub(); + + expect(launcher.launch).toHaveBeenCalledWith( + expect.objectContaining({ locale: 'en', debug: false }), + ); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('marks failed and returns the error when a step throws', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue(new Error('ukyc down')); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining('ukyc down'), + }); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + }); + + describe('reset', () => { + it('clears session state but preserves persisted terms', async () => { + await withController( + { + options: { + state: { + phase: 'form', + sessionToken: 'tok', + accessToken: 'a', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + kycRequiredByProduct: { ramps: true }, + }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + }, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + await withController(({ rootMessenger }) => { + expect( + rootMessenger.call('KycController:buildResetFrameUrl'), + ).toContain('ch_reset'); + }); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type ServiceHandlers = { + getGeoCountry: jest.Mock; + fetchDisclaimers: jest.Mock; + createSession: jest.Mock; + checkKycRequired: jest.Mock; + createUkycSession: jest.Mock; + submitWrappedKey: jest.Mock; +}; + +type Launcher = { + isAvailable: jest.Mock; + launch: jest.Mock; +}; + +type WithControllerCallback = (payload: { + controller: KycController; + rootMessenger: RootMessenger; + handlers: ServiceHandlers; + launcher: Launcher; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options: Partial[0]>; +}; + +const SERVICE_ACTIONS = [ + 'KycService:getGeoCountry', + 'KycService:fetchDisclaimers', + 'KycService:createSession', + 'KycService:checkKycRequired', + 'KycService:createUkycSession', + 'KycService:submitWrappedKey', +] as const; + +/** + * Wraps a test with a fully-wired controller, mocked service handlers, and a + * mocked SumSub launcher. + * + * @param args - Either a callback, or an options bag and a callback. + * @returns The callback's return value. + */ +function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): ReturnValue | Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + const messenger: KycControllerMessenger = new Messenger({ + namespace: 'KycController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: SERVICE_ACTIONS, + events: [], + messenger, + }); + + const handlers: ServiceHandlers = { + getGeoCountry: jest.fn().mockResolvedValue('USA'), + fetchDisclaimers: jest.fn().mockResolvedValue([]), + createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), + checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + createUkycSession: jest.fn().mockResolvedValue({ + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }), + submitWrappedKey: jest + .fn() + .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + }; + rootMessenger.registerActionHandler( + 'KycService:getGeoCountry', + handlers.getGeoCountry, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchDisclaimers', + handlers.fetchDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:createSession', + handlers.createSession, + ); + rootMessenger.registerActionHandler( + 'KycService:checkKycRequired', + handlers.checkKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:createUkycSession', + handlers.createUkycSession, + ); + rootMessenger.registerActionHandler( + 'KycService:submitWrappedKey', + handlers.submitWrappedKey, + ); + + const launcher: Launcher = { + isAvailable: jest.fn().mockReturnValue(true), + launch: jest.fn().mockResolvedValue({ ok: true }), + }; + + const controller = new KycController({ + messenger, + sumsubLauncher: launcher as unknown as KycSumSubLauncher, + ...options, + }); + + return testFunction({ controller, rootMessenger, handlers, launcher }); +} diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts new file mode 100644 index 00000000000..55b59d6a2ac --- /dev/null +++ b/packages/kyc-controller/src/KycController.ts @@ -0,0 +1,855 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; + +import { decryptCredentials, generateKeyPair } from './crypto'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto'; +import type { KycControllerMethodActions } from './KycController-method-action-types'; +import type { KycServiceMethodActions } from './KycService-method-action-types'; +import type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSumSubLauncher, + KycSumSubStatus, +} from './types'; + +// === GENERAL === + +export const controllerName = 'KycController'; + +const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; +const CHANNEL_CHECK = 'ch_1'; +const CHANNEL_AUTH = 'ch_2'; +const CHANNEL_RESET = 'ch_reset'; + +// Placeholder credentials for the SumSub sub-flow. These are demo values that +// must be replaced with real UKYC-issued material before production use. +const MOCK_JWT_TOKEN = 'mock-jwt-token'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link KycController}. + */ +export type KycControllerState = { + /** Current phase of the identity flow. */ + phase: KycPhase; + /** Human-readable status message for the current phase. */ + statusMessage: string; + /** The current error message, or `null`. */ + error: string | null; + + /** Email associated with the session (sourced from the account). */ + email: string | null; + + /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ + termsAcceptedAt: string | null; + /** IDs of the disclaimers the customer accepted (persisted). */ + acceptedDisclaimerIds: string[]; + + /** Disclaimers fetched for the current country. */ + disclaimers: KycDisclaimer[]; + /** Error encountered while loading disclaimers, or `null`. */ + disclaimersError: string | null; + + /** Resolved ISO 3166-1 alpha-3 country code. */ + geoCountry: string | null; + + /** Vendor session token (not persisted, not logged). */ + sessionToken: string | null; + /** Vendor access token (not persisted, not logged). */ + accessToken: string | null; + /** Vendor customer id, used for the SumSub hand-off. */ + moonpayCustomerId: string | null; + + /** Cached "is KYC required" result per product (persisted). */ + kycRequiredByProduct: Partial>; + /** ISO-8601 timestamp of the last KYC-required check (persisted). */ + lastCheckedAt: string | null; + + /** SumSub document-verification sub-flow state. */ + sumsub: { + status: KycSumSubStatus; + result: Json | null; + sessionId: string | null; + applicantAccessToken: string | null; + }; +}; + +const kycControllerMetadata = { + phase: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + statusMessage: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + error: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + email: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + termsAcceptedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + acceptedDisclaimerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + disclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + disclaimersError: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + geoCountry: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + accessToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayCustomerId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + kycRequiredByProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + lastCheckedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + sumsub: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link KycController} state. + * + * @returns The default state. + */ +export function getDefaultKycControllerState(): KycControllerState { + return { + phase: 'idle', + statusMessage: '', + error: null, + email: null, + termsAcceptedAt: null, + acceptedDisclaimerIds: [], + disclaimers: [], + disclaimersError: null, + geoCountry: null, + sessionToken: null, + accessToken: null, + moonpayCustomerId: null, + kycRequiredByProduct: {}, + lastCheckedAt: null, + sumsub: { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + }, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'initialize', + 'loadDisclaimers', + 'acceptTermsAndStartSession', + 'clearSavedTerms', + 'handleFrameMessage', + 'buildCheckFrameUrl', + 'buildAuthFrameUrl', + 'buildResetFrameUrl', + 'checkKycRequired', + 'getKycStatus', + 'startSumSub', + 'reset', +] as const; + +export type KycControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + KycControllerState +>; + +export type KycControllerActions = + | KycControllerGetStateAction + | KycControllerMethodActions; + +type AllowedActions = KycServiceMethodActions; + +export type KycControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + KycControllerState +>; + +export type KycControllerEvents = KycControllerStateChangeEvent; + +type AllowedEvents = never; + +export type KycControllerMessenger = Messenger< + typeof controllerName, + KycControllerActions | AllowedActions, + KycControllerEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycController}. + */ +export type KycControllerOptions = { + messenger: KycControllerMessenger; + state?: Partial; + /** + * Platform adapter that presents the SumSub SDK. Injected by each client so + * the controller stays platform-agnostic. + */ + sumsubLauncher: KycSumSubLauncher; +}; + +/** + * The shape of a message posted by a Check/Auth frame. + */ +type FrameMessage = { + meta?: { channelId?: string }; + kind?: string; + payload?: { + status?: + | 'active' + | 'connectionRequired' + | 'termsAcceptanceRequired' + | 'pending' + | 'unavailable' + | 'failed'; + credentials?: EncryptedCredentialsEnvelope | string; + customer?: { id?: string }; + }; +}; + +// === CONTROLLER DEFINITION === + +/** + * `KycController` orchestrates the vendor-backed KYC / identity-verification + * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per + * product surface used by ramps and card. It owns all state, HTTP + * orchestration (via `KycService`), crypto, and the frame message protocol; + * platform-specific presentation (WebView/iframe, SumSub SDK) is injected. + */ +export class KycController extends BaseController< + typeof controllerName, + KycControllerState, + KycControllerMessenger +> { + readonly #sumsubLauncher: KycSumSubLauncher; + + /** Ephemeral X25519 keypair for the frame key exchange (never persisted). */ + readonly #keypair: X25519KeyPair; + + /** Auth-frame client token, kept out of state. */ + #authClientToken: string | null = null; + + /** + * Constructs a new {@link KycController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - Partial initial state; merged over defaults. + * @param options.sumsubLauncher - The platform SumSub launcher adapter. + */ + constructor({ messenger, state, sumsubLauncher }: KycControllerOptions) { + super({ + messenger, + metadata: kycControllerMetadata, + name: controllerName, + state: { ...getDefaultKycControllerState(), ...state }, + }); + + this.#sumsubLauncher = sumsubLauncher; + this.#keypair = generateKeyPair(); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + */ + async initialize(params?: { email?: string }): Promise { + if (params?.email) { + this.update((state) => { + state.email = params.email as string; + }); + } + + // Resolve country for display; non-blocking. + try { + const country = await this.messenger.call('KycService:getGeoCountry'); + this.update((state) => { + state.geoCountry = country; + }); + } catch { + // Ignore; disclaimers loading will surface a country error if needed. + } + + const hasTerms = + Boolean(this.state.termsAcceptedAt) && + this.state.acceptedDisclaimerIds.length > 0; + + if (hasTerms && this.state.email) { + await this.#createSession(); + return; + } + + this.update((state) => { + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + + /** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ + async loadDisclaimers(params?: { country?: string }): Promise { + try { + const cachedCountry = params?.country ?? this.state.geoCountry; + const country = + cachedCountry ?? + (await this.messenger.call('KycService:getGeoCountry')); + if (!cachedCountry) { + this.update((state) => { + state.geoCountry = country; + }); + } + const disclaimers = await this.messenger.call( + 'KycService:fetchDisclaimers', + { country }, + ); + this.update((state) => { + state.disclaimers = disclaimers; + state.disclaimersError = null; + }); + } catch (error) { + this.update((state) => { + state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + }); + } + } + + /** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + */ + async acceptTermsAndStartSession(params?: { email?: string }): Promise { + const termsAcceptedAt = new Date().toISOString(); + const disclaimerIds = this.state.disclaimers.map( + (disclaimer) => disclaimer.id, + ); + this.update((state) => { + if (params?.email) { + state.email = params.email; + } + state.termsAcceptedAt = termsAcceptedAt; + state.acceptedDisclaimerIds = disclaimerIds; + }); + await this.#createSession(); + } + + /** + * Creates a vendor session from the currently stored terms + email. + */ + async #createSession(): Promise { + const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for session creation.'); + return; + } + if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) { + this.#fail('Missing terms acceptance for session creation.'); + return; + } + + this.update((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Creating session...'; + }); + + try { + const { sessionToken } = await this.messenger.call( + 'KycService:createSession', + { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, + ); + this.update((state) => { + state.sessionToken = sessionToken; + state.phase = 'check'; + state.statusMessage = 'Authenticating via Check frame...'; + }); + } catch (error) { + // Invalidate the stored acceptance so the customer can retry. + this.update((state) => { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + state.error = `Session creation failed: ${String(error)}`; + state.statusMessage = + 'Session creation failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + + /** + * Clears the persisted terms acceptance. + */ + clearSavedTerms(): void { + this.update((state) => { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + }); + } + + /** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ + async handleFrameMessage(params: { + message: unknown; + }): Promise<{ reply?: unknown }> { + const payload = params.message as FrameMessage | undefined; + + if (!payload) { + return {}; + } + + if (payload.kind === 'handshake') { + const channelId = payload.meta?.channelId; + return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; + } + + if (payload.kind !== 'complete') { + return {}; + } + + const channelId = payload.meta?.channelId; + const status = payload.payload?.status; + const credsEnvelope = payload.payload?.credentials; + + const customerId = payload.payload?.customer?.id ?? null; + if (customerId) { + this.update((state) => { + state.moonpayCustomerId = customerId; + }); + } + + if (!status) { + return {}; + } + + let accessToken: string | undefined; + let clientToken: string | undefined; + if (credsEnvelope) { + try { + const { credentials } = decryptCredentials( + credsEnvelope, + this.#keypair.privateKey, + ); + accessToken = credentials.accessToken; + clientToken = credentials.clientToken; + } catch (error) { + this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); + return {}; + } + } + + if (channelId === CHANNEL_CHECK) { + this.#handleCheckOutcome(status, accessToken, clientToken); + return {}; + } + + if (channelId === CHANNEL_AUTH) { + this.#handleAuthOutcome(status, accessToken); + return {}; + } + + return {}; + } + + /** + * Applies a Check-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + * @param clientToken - The decrypted client token, if any. + */ + #handleCheckOutcome( + status: NonNullable['status'], + accessToken?: string, + clientToken?: string, + ): void { + if (status === 'active' && accessToken) { + this.update((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Already authenticated. Review to submit.'; + }); + return; + } + if (status === 'connectionRequired' && clientToken) { + this.#authClientToken = clientToken; + this.update((state) => { + state.phase = 'auth'; + state.statusMessage = 'Verify your email via OTP in the Auth frame.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Check frame returned status: ${status}`); + } + + /** + * Applies an Auth-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + */ + #handleAuthOutcome( + status: NonNullable['status'], + accessToken?: string, + ): void { + if (status === 'active' && accessToken) { + this.update((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Authenticated. Review to submit.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Auth frame returned status: ${status}`); + } + + /** + * Invalidates stored terms and returns to the terms phase. + */ + #requireTermsReacceptance(): void { + this.update((state) => { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + state.phase = 'terms'; + state.statusMessage = + 'The vendor updated its Terms of Use — please re-accept.'; + }); + } + + /** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ + buildCheckFrameUrl(): string | null { + if (!this.state.sessionToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/check-connection`); + url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_CHECK); + url.searchParams.set('skipKyc', 'true'); + return url.toString(); + } + + /** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ + buildAuthFrameUrl(): string | null { + if (!this.#authClientToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/auth`); + url.searchParams.set('clientToken', this.#authClientToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_AUTH); + return url.toString(); + } + + /** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ + buildResetFrameUrl(): string { + const url = new URL(`${FRAMES_BASE_URL}/reset`); + url.searchParams.set('channelId', CHANNEL_RESET); + return url.toString(); + } + + /** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ + async checkKycRequired(params: { + product: KycProduct; + country?: string; + }): Promise { + const { accessToken } = this.state; + if (!accessToken) { + this.#fail('Missing accessToken — repeat the authentication step.'); + return false; + } + const country = params.country ?? this.state.geoCountry; + if (!country) { + this.#fail('Missing country for KYC-required check.'); + return false; + } + + this.update((state) => { + state.phase = 'submit'; + state.statusMessage = 'Checking KYC status...'; + }); + + try { + const { kycRequired } = await this.messenger.call( + 'KycService:checkKycRequired', + { accessToken, country, capabilities: [{ product: params.product }] }, + ); + this.update((state) => { + state.kycRequiredByProduct[params.product] = kycRequired; + state.lastCheckedAt = new Date().toISOString(); + state.phase = 'done'; + state.statusMessage = 'KYC check complete.'; + }); + return kycRequired; + } catch (error) { + this.#fail(`KYC check failed: ${String(error)}`); + return false; + } + } + + /** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ + getKycStatus(params: { product: KycProduct }): boolean | undefined { + return this.state.kycRequiredByProduct[params.product]; + } + + /** + * Runs the SumSub document-verification sub-flow: creates a UKYC session, + * exchanges the wrapped key for an applicant access token, and presents the + * SDK via the injected launcher. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ + async startSumSub(params?: { + locale?: string; + debug?: boolean; + }): Promise> { + if (!this.#sumsubLauncher.isAvailable()) { + const error = 'SumSub SDK is not available in this runtime.'; + this.update((state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = { error }; + }); + throw new Error(error); + } + + try { + this.update((state) => { + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + }); + + const jwtToken = MOCK_JWT_TOKEN; + const { sessionId, wrappingPublicKey, idosSessionId } = + await this.messenger.call('KycService:createUkycSession', { + jwtToken, + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }); + // Retain the exchange material so the SDK can refresh its token. The + // session's `wrappingPublicKey` is forwarded opaquely as the request's + // `wrappedUserKey` field (the /wrapped-key endpoint's body is unchanged). + const exchange = { + sessionId, + wrappedUserKey: wrappingPublicKey, + idosSessionId, + jwtToken, + }; + + this.update((state) => { + state.sumsub.status = 'fetchingToken'; + state.sumsub.sessionId = sessionId; + }); + + const { applicantAccessToken } = await this.messenger.call( + 'KycService:submitWrappedKey', + exchange, + ); + + this.update((state) => { + state.sumsub.status = 'launching'; + state.sumsub.applicantAccessToken = applicantAccessToken; + }); + + const result = await this.#sumsubLauncher.launch({ + applicantAccessToken, + onTokenExpiration: async () => { + const refreshed = await this.messenger.call( + 'KycService:submitWrappedKey', + exchange, + ); + return refreshed.applicantAccessToken; + }, + onStatusChange: (_prev, next) => { + this.update((state) => { + state.sumsub.status = + next === 'Completed' ? 'complete' : 'inProgress'; + }); + }, + locale: params?.locale ?? 'en', + debug: params?.debug ?? false, + }); + + this.update((state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = result as Json; + }); + return result; + } catch (error) { + const result = { error: String(error) }; + this.update((state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = result; + }); + return result; + } + } + + /** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ + reset(): void { + this.#authClientToken = null; + this.update((state) => { + state.phase = 'idle'; + state.statusMessage = ''; + state.error = null; + state.disclaimers = []; + state.disclaimersError = null; + state.sessionToken = null; + state.accessToken = null; + state.moonpayCustomerId = null; + state.sumsub = { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + }; + }); + } + + /** + * Transitions to the error phase with a message. + * + * @param message - The error message. + */ + #fail(message: string): void { + this.update((state) => { + state.error = message; + state.phase = 'error'; + }); + } +} diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts new file mode 100644 index 00000000000..844e36faf4c --- /dev/null +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -0,0 +1,87 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycService } from './KycService'; + +/** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ +export type KycServiceGetGeoCountryAction = { + type: `KycService:getGeoCountry`; + handler: KycService['getGeoCountry']; +}; + +/** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchDisclaimersAction = { + type: `KycService:fetchDisclaimers`; + handler: KycService['fetchDisclaimers']; +}; + +/** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ +export type KycServiceCreateSessionAction = { + type: `KycService:createSession`; + handler: KycService['createSession']; +}; + +/** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ +export type KycServiceCheckKycRequiredAction = { + type: `KycService:checkKycRequired`; + handler: KycService['checkKycRequired']; +}; + +/** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers and wrapped key. + */ +export type KycServiceCreateUkycSessionAction = { + type: `KycService:createUkycSession`; + handler: KycService['createUkycSession']; +}; + +/** + * Exchanges the wrapped user key for a SumSub applicant access token. + * + * @param params - The exchange parameters. + * @returns The applicant access token and status. + */ +export type KycServiceSubmitWrappedKeyAction = { + type: `KycService:submitWrappedKey`; + handler: KycService['submitWrappedKey']; +}; + +/** + * Union of all KycService action types. + */ +export type KycServiceMethodActions = + | KycServiceGetGeoCountryAction + | KycServiceFetchDisclaimersAction + | KycServiceCreateSessionAction + | KycServiceCheckKycRequiredAction + | KycServiceCreateUkycSessionAction + | KycServiceSubmitWrappedKeyAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts new file mode 100644 index 00000000000..c359add4e76 --- /dev/null +++ b/packages/kyc-controller/src/KycService.test.ts @@ -0,0 +1,352 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import type { KycServiceMessenger } from './KycService'; +import { KycService } from './KycService'; + +const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; + +describe('KycService', () => { + afterEach(() => { + cleanAll(); + }); + + describe('getGeoCountry', () => { + it('maps the geolocation to an ISO alpha-3 country code', async () => { + const { service } = getService({ geolocation: 'US-NY' }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + + it('throws when the location is unknown', async () => { + const { service } = getService({ geolocation: 'UNKNOWN' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('throws when the country cannot be mapped to alpha-3', async () => { + const { service } = getService({ geolocation: 'ZZ' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to map country code "ZZ"/u, + ); + }); + + it('throws when the location resolves to a nullish value', async () => { + const { service } = getService({ geolocation: null }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('constructs with the default service policy options', async () => { + const { service } = getService({ + defaultPolicy: true, + geolocation: 'US', + }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + }); + + describe('fetchDisclaimers', () => { + it('returns the disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws when no bearer token is available', async () => { + const { service } = getService({ bearerToken: '' }); + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(500); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('createSession', () => { + it('creates a session and returns the token', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/sessions') + .reply(200, { sessionToken: 'session-1' }); + const { service } = getService(); + + expect( + await service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).toStrictEqual({ sessionToken: 'session-1' }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/sessions').reply(200, {}); + const { service } = getService(); + + await expect( + service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).rejects.toThrow(/Malformed response received from sessions API/u); + }); + }); + + describe('checkKycRequired', () => { + it('returns whether KYC is required (default capabilities)', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }) + .reply(200, { required: true }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + }), + ).toStrictEqual({ kycRequired: true }); + }); + + it('passes provided capabilities', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }) + .reply(200, { required: false }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }), + ).toStrictEqual({ kycRequired: false }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); + }); + + it('surfaces the specific field mismatch and payload in the error', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required') + .reply(200, { required: 'yes' }); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from kyc-required API:.*required.*received: \{"required":"yes"\}/su, + ); + }); + }); + + describe('createUkycSession', () => { + it('creates a UKYC session', async () => { + const response = { + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }; + nock(MOCK_API_URL).post('/sessions').reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { foo: 'bar' }, + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/sessions').reply(200, { sessionId: 'sid' }); + const { service } = getService(); + + await expect( + service.createUkycSession({ jwtToken: 'jwt', vendorMetadata: {} }), + ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); + }); + }); + + describe('submitWrappedKey', () => { + it('exchanges the wrapped key for an applicant access token', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL).post('/sessions/sid/wrapped-key').reply(200, response); + const { service } = getService(); + + expect( + await service.submitWrappedKey({ + sessionId: 'sid', + wrappedUserKey: 'wuk', + idosSessionId: 'idos', + jwtToken: 'jwt', + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid/wrapped-key') + .reply(200, { status: 'ok' }); + const { service } = getService(); + + await expect( + service.submitWrappedKey({ + sessionId: 'sid', + wrappedUserKey: 'wuk', + idosSessionId: 'idos', + jwtToken: 'jwt', + }), + ).rejects.toThrow(/Malformed response received from wrapped-key API/u); + }); + }); + + describe('baseUrl override', () => { + it('uses the provided baseUrl instead of the env-derived URL', async () => { + const customUrl = 'https://kyc-api.local.test'; + const disclaimers = [{ id: '1', display_name: 'Terms', url: 'https://t' }]; + nock(customUrl) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService({ baseUrl: customUrl }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, []); + const { rootMessenger } = getService(); + + expect( + await rootMessenger.call('KycService:fetchDisclaimers', { + country: 'USA', + }), + ).toStrictEqual([]); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the service under test with mocked auth + geo handlers. + * + * @param args - Options. + * @param args.bearerToken - The bearer token the auth handler returns. + * @param args.geolocation - The location the geolocation handler returns. + * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. + * @param args.baseUrl - When provided, overrides the env-derived base URL. + * @returns The service, root messenger, and service messenger. + */ +function getService({ + bearerToken = 'test-bearer', + geolocation = 'US-NY', + defaultPolicy = false, + baseUrl, +}: { + bearerToken?: string; + geolocation?: string | null; + defaultPolicy?: boolean; + baseUrl?: string; +} = {}): { + service: KycService; + rootMessenger: RootMessenger; + messenger: KycServiceMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticationController:getBearerToken', + 'GeolocationController:getGeolocation', + ], + events: [], + messenger, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => bearerToken, + ); + rootMessenger.registerActionHandler( + 'GeolocationController:getGeolocation', + async () => geolocation as string, + ); + + const service = new KycService({ + fetch, + messenger, + env: 'development', + ...(baseUrl ? { baseUrl } : {}), + ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts new file mode 100644 index 00000000000..48117c65407 --- /dev/null +++ b/packages/kyc-controller/src/KycService.ts @@ -0,0 +1,418 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + string, + StructError, + type, +} from '@metamask/superstruct'; + +import { alpha2ToAlpha3 } from './countryCodes'; +import type { KycServiceMethodActions } from './KycService-method-action-types'; +import type { KycDisclaimer } from './types'; + +// === GENERAL === + +/** + * The name of the {@link KycService}, used to namespace the service's actions. + */ +export const serviceName = 'KycService'; + +/** + * The supported environments for the Universal KYC API. + */ +export type KycServiceEnvironment = 'production' | 'development'; + +const KYC_API_URLS: Record = { + production: 'https://kyc-api.cx.metamask.io', + development: 'https://kyc-api.dev-api.cx.metamask.io', +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getGeoCountry', + 'fetchDisclaimers', + 'createSession', + 'checkKycRequired', + 'createUkycSession', + 'submitWrappedKey', +] as const; + +/** + * Actions that {@link KycService} exposes to other consumers. + */ +export type KycServiceActions = KycServiceMethodActions; + +/** + * Actions from other messengers that {@link KycService} calls. + */ +type AllowedActions = + | AuthenticationControllerGetBearerTokenAction + | GeolocationControllerGetGeolocationAction; + +/** + * Events that {@link KycService} exposes to other consumers. + */ +export type KycServiceEvents = never; + +/** + * Events from other messengers that {@link KycService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link KycService}. + */ +export type KycServiceMessenger = Messenger< + typeof serviceName, + KycServiceActions | AllowedActions, + KycServiceEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycService}. + */ +export type KycServiceOptions = { + messenger: KycServiceMessenger; + fetch: typeof fetch; + env: KycServiceEnvironment; + /** + * Overrides the base URL derived from `env`. When provided, this value is + * used verbatim as the base URL for all requests, which is useful for + * targeting a local or staging KYC API. + */ + baseUrl?: string; + policyOptions?: CreateServicePolicyOptions; +}; + +// === API RESPONSE SCHEMAS === + +const DisclaimerStruct = type({ + id: string(), + display_name: string(), + url: string(), +}); +const DisclaimersResponseStruct = array(DisclaimerStruct); + +const CreateSessionResponseStruct = type({ sessionToken: string() }); + +// The live KYC API returns the flag under `required`; the service normalizes +// this to `kycRequired` for consumers (see `checkKycRequired`). +const KycRequiredResponseStruct = type({ required: boolean() }); + +const UkycSessionResponseStruct = type({ + sessionId: string(), + wrappingPublicKey: string(), + idosSessionId: string(), +}); +export type UkycSessionResponse = Infer; + +const WrappedKeyResponseStruct = type({ + status: string(), + applicantAccessToken: string(), +}); +export type WrappedKeyResponse = Infer; + +// === PARAM TYPES === + +export type CreateSessionParams = { + email: string; + termsAcceptedAt: string; + disclaimerIds: string[]; +}; + +export type CheckKycRequiredParams = { + accessToken: string; + country: string; + capabilities?: { product: string }[]; +}; + +export type CreateUkycSessionParams = { + jwtToken: string; + vendorMetadata: Record; +}; + +export type SubmitWrappedKeyParams = { + sessionId: string; + wrappedUserKey: string; + idosSessionId: string; + jwtToken: string; +}; + +// === SERVICE DEFINITION === + +/** + * `KycService` communicates with the Universal KYC (UKYC) backend to drive the + * identity + document-verification flow. It is stateless and platform-agnostic: + * HTTP is performed through an injected `fetch`, and the auth bearer token and + * geolocation come from other controllers via the messenger. + */ +export class KycService { + readonly name: typeof serviceName; + + readonly #messenger: KycServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #baseUrl: string; + + readonly #policy: ServicePolicy; + + /** + * Constructs a new KycService. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this service. + * @param options.fetch - A function used to make HTTP requests. + * @param options.env - The environment; determines the base URL. + * @param options.baseUrl - Overrides the base URL derived from `env`. + * @param options.policyOptions - Options for the request service policy. + */ + constructor({ + messenger, + fetch: fetchFunction, + env, + baseUrl, + policyOptions, + }: KycServiceOptions) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#baseUrl = baseUrl ?? KYC_API_URLS[env]; + this.#policy = createServicePolicy(policyOptions ?? {}); + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ + async getGeoCountry(): Promise { + const location = await this.#messenger.call( + 'GeolocationController:getGeolocation', + ); + const alpha2 = String(location ?? '') + .split('-')[0] + .toUpperCase(); + if (!alpha2 || alpha2 === 'UNKNOWN') { + throw new Error( + `Unable to determine country from geolocation (got "${location}").`, + ); + } + const alpha3 = alpha2ToAlpha3(alpha2); + if (!alpha3) { + throw new Error( + `Unable to map country code "${alpha2}" to an ISO 3166-1 alpha-3 code.`, + ); + } + return alpha3; + } + + /** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.#request(url, { method: 'GET' }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ + async createSession( + params: CreateSessionParams, + ): Promise> { + const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify(params), + }); + return this.#validateResponse(data, CreateSessionResponseStruct, 'sessions'); + } + + /** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ + async checkKycRequired( + params: CheckKycRequiredParams, + ): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + accessToken: params.accessToken, + country: params.country, + capabilities: params.capabilities ?? [{ product: 'ramps' }], + }), + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers and wrapped key. + */ + async createUkycSession( + params: CreateUkycSessionParams, + ): Promise { + const url = new URL('/sessions', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + vendorMetadata: params.vendorMetadata, + }), + }); + return this.#validateResponse( + data, + UkycSessionResponseStruct, + 'UKYC sessions', + ); + } + + /** + * Exchanges the wrapped user key for a SumSub applicant access token. + * + * @param params - The exchange parameters. + * @returns The applicant access token and status. + */ + async submitWrappedKey( + params: SubmitWrappedKeyParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/wrapped-key`, + this.#baseUrl, + ); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + wrappedUserKey: params.wrappedUserKey, + jwtToken: params.jwtToken, + idosSessionId: params.idosSessionId, + }), + }); + return this.#validateResponse( + data, + WrappedKeyResponseStruct, + 'wrapped-key', + ); + } + + /** + * Validates a parsed API response against a superstruct schema, throwing a + * descriptive error when the response does not match. + * + * Unlike a bare `Struct.is` check, this surfaces exactly which field was + * missing or had the wrong type, which is essential for diagnosing shape + * mismatches between the client and the live API. + * + * @param data - The parsed response body. + * @param struct - The superstruct schema the body is expected to satisfy. + * @param apiName - A human-readable name of the API, used in the error message. + * @returns The validated, typed response. + * @throws If `data` does not match `struct`. + */ + #validateResponse( + data: unknown, + struct: Struct, + apiName: string, + ): Type { + try { + assert(data, struct); + return data; + } catch (error) { + const detail = + error instanceof StructError + ? `${error.message} (received: ${JSON.stringify(data)})` + : String(error); + throw new Error( + `Malformed response received from ${apiName} API: ${detail}`, + ); + } + } + + /** + * Performs an authenticated JSON request wrapped in the service policy. + * + * @param url - The request URL. + * @param init - The request init (method, body). + * @returns The parsed JSON response. + */ + async #request(url: URL, init: RequestInit): Promise { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token — is the wallet signed in?', + ); + } + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url.toString(), { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + + return response.json(); + } +} diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts new file mode 100644 index 00000000000..55446663199 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.test.ts @@ -0,0 +1,19 @@ +import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes'; + +describe('countryCodes', () => { + it('exposes the alpha-2 to alpha-3 map', () => { + expect(ALPHA2_TO_ALPHA3.US).toBe('USA'); + }); + + it('maps a known uppercase alpha-2 code', () => { + expect(alpha2ToAlpha3('GB')).toBe('GBR'); + }); + + it('is case-insensitive', () => { + expect(alpha2ToAlpha3('fr')).toBe('FRA'); + }); + + it('returns undefined for an unknown code', () => { + expect(alpha2ToAlpha3('ZZ')).toBeUndefined(); + }); +}); diff --git a/packages/kyc-controller/src/countryCodes.ts b/packages/kyc-controller/src/countryCodes.ts new file mode 100644 index 00000000000..a5712d24109 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.ts @@ -0,0 +1,270 @@ +/** + * ISO 3166-1 alpha-2 to alpha-3 country code mapping. + * + * The geolocation source returns ISO 3166-2 codes whose leading segment is an + * alpha-2 country code (e.g. "US", "US-NY"). The identity vendor APIs + * (disclaimers, kyc-required) expect alpha-3 codes (e.g. "USA"). This map + * bridges the two. + */ +export const ALPHA2_TO_ALPHA3: Record = { + AD: 'AND', + AE: 'ARE', + AF: 'AFG', + AG: 'ATG', + AI: 'AIA', + AL: 'ALB', + AM: 'ARM', + AO: 'AGO', + AQ: 'ATA', + AR: 'ARG', + AS: 'ASM', + AT: 'AUT', + AU: 'AUS', + AW: 'ABW', + AX: 'ALA', + AZ: 'AZE', + BA: 'BIH', + BB: 'BRB', + BD: 'BGD', + BE: 'BEL', + BF: 'BFA', + BG: 'BGR', + BH: 'BHR', + BI: 'BDI', + BJ: 'BEN', + BL: 'BLM', + BM: 'BMU', + BN: 'BRN', + BO: 'BOL', + BQ: 'BES', + BR: 'BRA', + BS: 'BHS', + BT: 'BTN', + BV: 'BVT', + BW: 'BWA', + BY: 'BLR', + BZ: 'BLZ', + CA: 'CAN', + CC: 'CCK', + CD: 'COD', + CF: 'CAF', + CG: 'COG', + CH: 'CHE', + CI: 'CIV', + CK: 'COK', + CL: 'CHL', + CM: 'CMR', + CN: 'CHN', + CO: 'COL', + CR: 'CRI', + CU: 'CUB', + CV: 'CPV', + CW: 'CUW', + CX: 'CXR', + CY: 'CYP', + CZ: 'CZE', + DE: 'DEU', + DJ: 'DJI', + DK: 'DNK', + DM: 'DMA', + DO: 'DOM', + DZ: 'DZA', + EC: 'ECU', + EE: 'EST', + EG: 'EGY', + EH: 'ESH', + ER: 'ERI', + ES: 'ESP', + ET: 'ETH', + FI: 'FIN', + FJ: 'FJI', + FK: 'FLK', + FM: 'FSM', + FO: 'FRO', + FR: 'FRA', + GA: 'GAB', + GB: 'GBR', + GD: 'GRD', + GE: 'GEO', + GF: 'GUF', + GG: 'GGY', + GH: 'GHA', + GI: 'GIB', + GL: 'GRL', + GM: 'GMB', + GN: 'GIN', + GP: 'GLP', + GQ: 'GNQ', + GR: 'GRC', + GS: 'SGS', + GT: 'GTM', + GU: 'GUM', + GW: 'GNB', + GY: 'GUY', + HK: 'HKG', + HM: 'HMD', + HN: 'HND', + HR: 'HRV', + HT: 'HTI', + HU: 'HUN', + ID: 'IDN', + IE: 'IRL', + IL: 'ISR', + IM: 'IMN', + IN: 'IND', + IO: 'IOT', + IQ: 'IRQ', + IR: 'IRN', + IS: 'ISL', + IT: 'ITA', + JE: 'JEY', + JM: 'JAM', + JO: 'JOR', + JP: 'JPN', + KE: 'KEN', + KG: 'KGZ', + KH: 'KHM', + KI: 'KIR', + KM: 'COM', + KN: 'KNA', + KP: 'PRK', + KR: 'KOR', + KW: 'KWT', + KY: 'CYM', + KZ: 'KAZ', + LA: 'LAO', + LB: 'LBN', + LC: 'LCA', + LI: 'LIE', + LK: 'LKA', + LR: 'LBR', + LS: 'LSO', + LT: 'LTU', + LU: 'LUX', + LV: 'LVA', + LY: 'LBY', + MA: 'MAR', + MC: 'MCO', + MD: 'MDA', + ME: 'MNE', + MF: 'MAF', + MG: 'MDG', + MH: 'MHL', + MK: 'MKD', + ML: 'MLI', + MM: 'MMR', + MN: 'MNG', + MO: 'MAC', + MP: 'MNP', + MQ: 'MTQ', + MR: 'MRT', + MS: 'MSR', + MT: 'MLT', + MU: 'MUS', + MV: 'MDV', + MW: 'MWI', + MX: 'MEX', + MY: 'MYS', + MZ: 'MOZ', + NA: 'NAM', + NC: 'NCL', + NE: 'NER', + NF: 'NFK', + NG: 'NGA', + NI: 'NIC', + NL: 'NLD', + NO: 'NOR', + NP: 'NPL', + NR: 'NRU', + NU: 'NIU', + NZ: 'NZL', + OM: 'OMN', + PA: 'PAN', + PE: 'PER', + PF: 'PYF', + PG: 'PNG', + PH: 'PHL', + PK: 'PAK', + PL: 'POL', + PM: 'SPM', + PN: 'PCN', + PR: 'PRI', + PS: 'PSE', + PT: 'PRT', + PW: 'PLW', + PY: 'PRY', + QA: 'QAT', + RE: 'REU', + RO: 'ROU', + RS: 'SRB', + RU: 'RUS', + RW: 'RWA', + SA: 'SAU', + SB: 'SLB', + SC: 'SYC', + SD: 'SDN', + SE: 'SWE', + SG: 'SGP', + SH: 'SHN', + SI: 'SVN', + SJ: 'SJM', + SK: 'SVK', + SL: 'SLE', + SM: 'SMR', + SN: 'SEN', + SO: 'SOM', + SR: 'SUR', + SS: 'SSD', + ST: 'STP', + SV: 'SLV', + SX: 'SXM', + SY: 'SYR', + SZ: 'SWZ', + TC: 'TCA', + TD: 'TCD', + TF: 'ATF', + TG: 'TGO', + TH: 'THA', + TJ: 'TJK', + TK: 'TKL', + TL: 'TLS', + TM: 'TKM', + TN: 'TUN', + TO: 'TON', + TR: 'TUR', + TT: 'TTO', + TV: 'TUV', + TW: 'TWN', + TZ: 'TZA', + UA: 'UKR', + UG: 'UGA', + UM: 'UMI', + US: 'USA', + UY: 'URY', + UZ: 'UZB', + VA: 'VAT', + VC: 'VCT', + VE: 'VEN', + VG: 'VGB', + VI: 'VIR', + VN: 'VNM', + VU: 'VUT', + WF: 'WLF', + WS: 'WSM', + YE: 'YEM', + YT: 'MYT', + ZA: 'ZAF', + ZM: 'ZMB', + ZW: 'ZWE', +}; + +/** + * Converts an ISO 3166-1 alpha-2 country code (e.g. "US") to its alpha-3 + * equivalent (e.g. "USA"). Returns `undefined` for unknown codes. + * + * @param alpha2 - The ISO 3166-1 alpha-2 country code. + * @returns The alpha-3 code, or `undefined` if the input is not recognized. + */ +export function alpha2ToAlpha3(alpha2: string): string | undefined { + return ALPHA2_TO_ALPHA3[alpha2.toUpperCase()]; +} diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts new file mode 100644 index 00000000000..b26d985c9ac --- /dev/null +++ b/packages/kyc-controller/src/crypto.test.ts @@ -0,0 +1,204 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +import type { EncryptedCredentialsEnvelope } from './crypto'; +import { decryptCredentials, generateKeyPair } from './crypto'; + +/** + * Builds an encrypted-credentials envelope that `decryptCredentials` can + * reverse with `ourPublicKey`'s matching private key. + * + * @param ourPublicKey - The recipient's X25519 public key. + * @param credentials - The plaintext credentials to encrypt. + * @param options - Encoding options. + * @param options.encoding - `'hex'` (default) or `'base64'`. + * @param options.ivLength - IV length in bytes (default 12). + * @param options.useNonceField - Emit `nonce` instead of `iv`. + * @returns The encrypted envelope. + */ +function makeEnvelope( + ourPublicKey: Uint8Array, + credentials: Record, + { + encoding = 'hex' as 'hex' | 'base64', + ivLength = 12, + useNonceField = false, + } = {}, +): EncryptedCredentialsEnvelope { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, ourPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(ivLength).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + const encode = (bytes: Uint8Array): string => + encoding === 'hex' ? bytesToHex(bytes) : base64.encode(bytes); + const envelope: EncryptedCredentialsEnvelope = { + ephemeralPublicKey: encode(ephemeralPublic), + ciphertext: encode(ciphertext), + }; + if (useNonceField) { + envelope.nonce = encode(iv); + } else { + envelope.iv = encode(iv); + } + return envelope; +} + +describe('crypto', () => { + describe('generateKeyPair', () => { + it('produces a 32-byte keypair with a hex public key', () => { + const keypair = generateKeyPair(); + expect(keypair.privateKey).toHaveLength(32); + expect(keypair.publicKey).toHaveLength(32); + expect(keypair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/u); + }); + }); + + describe('decryptCredentials', () => { + it('decrypts a hex-encoded envelope object', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-1', + }); + + const { credentials, method } = decryptCredentials( + envelope, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-1'); + expect(method).toBe('aes-256-gcm/hkdf-sha256'); + }); + + it('decrypts a base64-encoded envelope', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { clientToken: 'client-1' }, + { encoding: 'base64' }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.clientToken).toBe('client-1'); + }); + + it('honors an explicit base64 encoding hint', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-2' }, + { encoding: 'base64' }, + ); + envelope.encoding = 'base64'; + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-2'); + }); + + it('accepts a `nonce` field as an alias for `iv`', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-3' }, + { useNonceField: true }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-3'); + }); + + it('decrypts an envelope delivered as a JSON string', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-4', + }); + + const { credentials } = decryptCredentials( + JSON.stringify(envelope), + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-4'); + }); + + it('decrypts an envelope delivered as base64(JSON)', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-5', + }); + const base64Json = base64.encode(utf8ToBytes(JSON.stringify(envelope))); + + const { credentials } = decryptCredentials( + base64Json, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-5'); + }); + + it('throws for a JSON string that fails to parse', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials('{ not valid json', keypair.privateKey), + ).toThrow(/looked like JSON but failed to parse/u); + }); + + it('throws for base64 that decodes to non-JSON starting with a brace', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('{ still not json')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /base64-decoded to non-JSON/u, + ); + }); + + it('throws for an opaque string that is neither JSON nor base64(JSON)', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('hello world')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /opaque string/u, + ); + }); + + it('throws for an object missing required fields', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + { ephemeralPublicKey: 'aa' } as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/missing required fields/u); + }); + + it('reports the value type for a non-object input', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + 123 as unknown as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/Got: number/u); + }); + + it('throws when the IV length is not 12 bytes', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'x' }, + { ivLength: 16 }, + ); + expect(() => decryptCredentials(envelope, keypair.privateKey)).toThrow( + /Unexpected IV length 16/u, + ); + }); + }); +}); diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts new file mode 100644 index 00000000000..e54ea730915 --- /dev/null +++ b/packages/kyc-controller/src/crypto.ts @@ -0,0 +1,252 @@ +/** + * Check / Auth frame key exchange and credential decryption. + * + * The identity vendor's Check and Auth frames return encrypted credentials. + * The confirmed protocol is X25519 ECDH + AES-256-GCM (an "ECDH-ES" pattern + * signalled by a 12-byte IV): + * + * 1. Client generates an X25519 keypair, sends `publicKey` (hex) into the + * frame as a URL param. + * 2. Frame generates its own ephemeral X25519 keypair and encrypts the + * credentials, returning `{ ephemeralPublicKey, iv, ciphertext }`. + * 3. Client reverses: + * shared = X25519(ourPrivate, theirEphemeralPublic) + * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes) + * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag) + * + * This module is platform-agnostic: it uses `@noble/*` + `@scure/base` and + * avoids `Buffer` / `atob` so it runs unchanged on mobile, extension, and web. + */ + +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +/** + * An X25519 keypair used for the Check/Auth frame key exchange. + */ +export type X25519KeyPair = { + /** Raw 32-byte X25519 private (scalar) key. Never leaves the device. */ + privateKey: Uint8Array; + /** Raw 32-byte X25519 public key. */ + publicKey: Uint8Array; + /** Hex-encoded public key, ready to drop into a Check/Auth frame URL. */ + publicKeyHex: string; +}; + +/** + * The encrypted-credentials envelope returned by the Check/Auth frames. Binary + * fields may be hex or base64; the IV field may be named `iv` or `nonce`. + */ +export type EncryptedCredentialsEnvelope = { + /** Ephemeral public key produced by the frame for this exchange (32 bytes). */ + ephemeralPublicKey: string; + /** Per-message IV. May be provided as `iv` or `nonce`. */ + iv?: string; + nonce?: string; + /** Ciphertext (plaintext + 16-byte GCM auth tag). */ + ciphertext: string; + /** Optional explicit encoding hint. Defaults to auto-detect. */ + encoding?: 'hex' | 'base64'; +}; + +/** + * Decrypted Check/Auth frame credentials. + * + * - `accessToken` is the Bearer token for the identity API. + * - `clientToken` is the short-lived token consumed by the Auth frame when the + * Check frame returns `connectionRequired`. + */ +export type DecryptedCredentials = { + accessToken?: string; + clientToken?: string; + [key: string]: unknown; +}; + +/** + * Result of a successful decryption — the credentials plus the `method` that + * authenticated. + */ +export type DecryptResult = { + credentials: DecryptedCredentials; + method: string; +}; + +/** + * Generate a fresh X25519 keypair. The private key never leaves the device; + * only `publicKeyHex` is sent to the vendor via the frame URL. + * + * @returns The generated keypair. + */ +export function generateKeyPair(): X25519KeyPair { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { + privateKey, + publicKey, + publicKeyHex: bytesToHex(publicKey), + }; +} + +/** + * Decode a base64 / base64url string to bytes without relying on `atob` or + * `Buffer`. + * + * @param value - The (possibly url-safe, possibly unpadded) base64 string. + * @returns The decoded bytes. + */ +function base64ToBytes(value: string): Uint8Array { + const normalized = value.replace(/-/gu, '+').replace(/_/gu, '/'); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ); + return base64.decode(padded); +} + +/** + * Decode a binary envelope field that may be hex or base64. + * + * @param value - The encoded field. + * @param encoding - Optional explicit encoding; auto-detected when omitted. + * @returns The decoded bytes. + */ +function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array { + const isHex = + encoding === 'hex' || + (encoding === undefined && /^[0-9a-fA-F]+$/u.test(value)); + if (isHex) { + return hexToBytes(value); + } + return base64ToBytes(value); +} + +/** + * Coerce the `credentials` field into a structured envelope. The frame may + * deliver it as an object, a JSON string, or base64(JSON). + * + * @param input - The raw credentials value. + * @returns The normalized envelope. + * @throws If the value is not a structured or base64(JSON) envelope, or is + * missing required fields. + */ +function normalizeEnvelope( + input: EncryptedCredentialsEnvelope | string, +): EncryptedCredentialsEnvelope { + let value: unknown = input; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{')) { + try { + value = JSON.parse(trimmed); + } catch { + throw new Error( + `credentials looked like JSON but failed to parse (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + let decodedText: string | null = null; + try { + decodedText = new TextDecoder().decode(base64ToBytes(trimmed)); + } catch { + decodedText = null; + } + const decodedTrimmed = decodedText?.trim(); + if (decodedTrimmed?.startsWith('{')) { + try { + value = JSON.parse(decodedTrimmed); + } catch { + throw new Error( + `credentials base64-decoded to non-JSON (preview: "${decodedTrimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + throw new Error( + `credentials is an opaque string, not a structured or base64(JSON) envelope (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } + } + + const env = value as Partial; + if (!env.ephemeralPublicKey || !(env.iv ?? env.nonce) || !env.ciphertext) { + const keys = + value && typeof value === 'object' + ? Object.keys(value).join(', ') + : typeof value; + throw new Error( + `credentials envelope missing required fields (ephemeralPublicKey/iv/ciphertext). Got: ${keys}`, + ); + } + return env as EncryptedCredentialsEnvelope; +} + +/** + * X25519 ECDH to AES-256-GCM decryption. + * + * @param theirPublicKey - The frame's ephemeral public key. + * @param iv - The 12-byte GCM IV. + * @param ciphertext - The ciphertext including the 16-byte auth tag. + * @param ourPrivateKey - Our X25519 private key. + * @returns The decrypted credentials and method. + */ +function aesGcmDecrypt( + theirPublicKey: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, + ourPrivateKey: Uint8Array, +): DecryptResult { + const shared = x25519.getSharedSecret(ourPrivateKey, theirPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const plaintext = gcm(key, iv).decrypt(ciphertext); + const text = new TextDecoder().decode(plaintext); + return { + credentials: JSON.parse(text) as DecryptedCredentials, + method: 'aes-256-gcm/hkdf-sha256', + }; +} + +/** + * Decrypt a Check/Auth frame credentials envelope using our X25519 private + * key. + * + * @param rawEnvelope - The raw envelope (object, JSON string, or base64(JSON)). + * @param ourPrivateKey - Our X25519 private key. + * @returns The parsed credentials and the method that authenticated. + * @throws If the envelope is malformed or the IV length is not 12 bytes. + */ +export function decryptCredentials( + rawEnvelope: EncryptedCredentialsEnvelope | string, + ourPrivateKey: Uint8Array, +): DecryptResult { + const envelope = normalizeEnvelope(rawEnvelope); + const theirPublicKey = decodeBinary( + envelope.ephemeralPublicKey, + envelope.encoding, + ); + // `normalizeEnvelope` guarantees one of `iv` / `nonce` is present. + const ivField = (envelope.iv ?? envelope.nonce) as string; + const iv = decodeBinary(ivField, envelope.encoding); + const ciphertext = decodeBinary(envelope.ciphertext, envelope.encoding); + + if (iv.length !== 12) { + throw new Error( + `Unexpected IV length ${iv.length} (expected 12 for AES-256-GCM).`, + ); + } + + return aesGcmDecrypt(theirPublicKey, iv, ciphertext, ourPrivateKey); +} diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts new file mode 100644 index 00000000000..b95071359e1 --- /dev/null +++ b/packages/kyc-controller/src/index.test.ts @@ -0,0 +1,19 @@ +import * as packageExports from '.'; + +describe('@metamask/kyc-controller', () => { + it('exports the controller, service, selectors, and helpers', () => { + expect(packageExports).toMatchObject({ + KycController: expect.any(Function), + KycService: expect.any(Function), + getDefaultKycControllerState: expect.any(Function), + selectKycPhase: expect.any(Function), + selectKycSumSub: expect.any(Function), + selectIsKycRequiredForProduct: expect.any(Function), + alpha2ToAlpha3: expect.any(Function), + generateKeyPair: expect.any(Function), + decryptCredentials: expect.any(Function), + controllerName: 'KycController', + serviceName: 'KycService', + }); + }); +}); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts new file mode 100644 index 00000000000..816afb882b8 --- /dev/null +++ b/packages/kyc-controller/src/index.ts @@ -0,0 +1,76 @@ +export { + KycController, + getDefaultKycControllerState, + controllerName, +} from './KycController'; +export type { + KycControllerActions, + KycControllerEvents, + KycControllerGetStateAction, + KycControllerMessenger, + KycControllerOptions, + KycControllerState, + KycControllerStateChangeEvent, +} from './KycController'; +export type { + KycControllerAcceptTermsAndStartSessionAction, + KycControllerBuildAuthFrameUrlAction, + KycControllerBuildCheckFrameUrlAction, + KycControllerBuildResetFrameUrlAction, + KycControllerCheckKycRequiredAction, + KycControllerClearSavedTermsAction, + KycControllerGetKycStatusAction, + KycControllerHandleFrameMessageAction, + KycControllerInitializeAction, + KycControllerLoadDisclaimersAction, + KycControllerResetAction, + KycControllerStartSumSubAction, +} from './KycController-method-action-types'; + +export { KycService, serviceName } from './KycService'; +export type { + CheckKycRequiredParams, + CreateSessionParams, + CreateUkycSessionParams, + KycServiceActions, + KycServiceEnvironment, + KycServiceEvents, + KycServiceMessenger, + KycServiceOptions, + SubmitWrappedKeyParams, + UkycSessionResponse, + WrappedKeyResponse, +} from './KycService'; +export type { + KycServiceCheckKycRequiredAction, + KycServiceCreateSessionAction, + KycServiceCreateUkycSessionAction, + KycServiceFetchDisclaimersAction, + KycServiceGetGeoCountryAction, + KycServiceSubmitWrappedKeyAction, +} from './KycService-method-action-types'; + +export { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors'; + +export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes'; +export { decryptCredentials, generateKeyPair } from './crypto'; +export type { + DecryptedCredentials, + DecryptResult, + EncryptedCredentialsEnvelope, + X25519KeyPair, +} from './crypto'; + +export type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSumSubLaunchParams, + KycSumSubLauncher, + KycSumSubStatus, + KycVendor, +} from './types'; diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts new file mode 100644 index 00000000000..725ec0e592d --- /dev/null +++ b/packages/kyc-controller/src/selectors.test.ts @@ -0,0 +1,33 @@ +import { getDefaultKycControllerState } from './KycController'; +import { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors'; + +describe('selectors', () => { + it('selectKycPhase returns the current phase', () => { + const state = { ...getDefaultKycControllerState(), phase: 'form' as const }; + expect(selectKycPhase(state)).toBe('form'); + }); + + it('selectKycSumSub returns the sub-flow state', () => { + const state = getDefaultKycControllerState(); + expect(selectKycSumSub(state)).toStrictEqual(state.sumsub); + }); + + describe('selectIsKycRequiredForProduct', () => { + it('returns the cached requirement for a product', () => { + const state = { + ...getDefaultKycControllerState(), + kycRequiredByProduct: { ramps: true }, + }; + expect(selectIsKycRequiredForProduct('ramps')(state)).toBe(true); + }); + + it('returns undefined when the product has not been checked', () => { + const state = getDefaultKycControllerState(); + expect(selectIsKycRequiredForProduct('card')(state)).toBeUndefined(); + }); + }); +}); diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts new file mode 100644 index 00000000000..658169a15fc --- /dev/null +++ b/packages/kyc-controller/src/selectors.ts @@ -0,0 +1,42 @@ +import { createSelector } from 'reselect'; + +import type { KycControllerState } from './KycController'; +import type { KycProduct } from './types'; + +const selectKycRequiredByProduct = ( + state: KycControllerState, +): KycControllerState['kycRequiredByProduct'] => state.kycRequiredByProduct; + +/** + * Selects the current flow phase. + * + * @param state - The KycController state. + * @returns The current phase. + */ +export const selectKycPhase = ( + state: KycControllerState, +): KycControllerState['phase'] => state.phase; + +/** + * Selects the SumSub sub-flow state. + * + * @param state - The KycController state. + * @returns The SumSub state. + */ +export const selectKycSumSub = ( + state: KycControllerState, +): KycControllerState['sumsub'] => state.sumsub; + +/** + * Creates a selector that returns whether KYC is required for a product. + * + * @param product - The consuming feature. + * @returns A selector returning the cached requirement, or `undefined`. + */ +export const selectIsKycRequiredForProduct = ( + product: KycProduct, +): ((state: KycControllerState) => boolean | undefined) => + createSelector( + [selectKycRequiredByProduct], + (map): boolean | undefined => map[product], + ); diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts new file mode 100644 index 00000000000..4d41052f8fa --- /dev/null +++ b/packages/kyc-controller/src/types.ts @@ -0,0 +1,119 @@ +/** + * Shared types for the KYC controller and service. + * + * The KYC flow is vendor-backed (currently MoonPay for identity + SumSub for + * document verification) but the surface exposed to consumers (ramps, card) is + * intentionally vendor-neutral so a future vendor swap does not ripple out. + */ + +/** + * A MetaMask feature that consumes KYC. Used to key the per-product + * "is KYC required" cache so ramps and card can share one controller. + */ +export type KycProduct = 'ramps' | 'card'; + +/** + * Identity vendors supported behind the KYC surface. + */ +export type KycVendor = 'moonpay'; + +/** + * Phases of the end-to-end identity flow. + * + * - `idle` — nothing started. + * - `terms` — waiting for the customer to accept the vendor terms. + * - `session` — creating the vendor session. + * - `check` — running the invisible connection-check frame. + * - `auth` — running the visible authentication (OTP) frame. + * - `form` — authenticated; ready to submit the identity check. + * - `submit` — submitting the KYC-required check. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. + * - `error` — flow halted; see `error`. + */ +export type KycPhase = + | 'idle' + | 'terms' + | 'session' + | 'check' + | 'auth' + | 'form' + | 'submit' + | 'done' + | 'error'; + +/** + * Progress of the SumSub document-verification sub-flow. + */ +export type KycSumSubStatus = + | 'idle' + | 'creatingSession' + | 'fetchingToken' + | 'launching' + | 'inProgress' + | 'complete' + | 'failed'; + +/** + * A single disclaimer/term the customer must accept before a session is + * created. + */ +export type KycDisclaimer = { + id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + display_name: string; + url: string; +}; + +/** + * Parameters passed to a platform SumSub launcher. + */ +export type KycSumSubLaunchParams = { + /** + * The applicant access token used to initialize the SumSub SDK. + */ + applicantAccessToken: string; + + /** + * Called by the SDK when the access token expires; must resolve with a fresh + * applicant access token. + */ + onTokenExpiration: () => Promise; + + /** + * Called when the SDK reports a status transition. + */ + onStatusChange?: (prevStatus: string, newStatus: string) => void; + + /** + * BCP-47 locale for the SDK UI. + */ + locale?: string; + + /** + * Enables SDK debug logging. + */ + debug?: boolean; +}; + +/** + * Platform adapter that launches the native/web SumSub SDK. + * + * The KYC controller is platform-agnostic and does not import any SDK; each + * client (mobile / extension / web) injects an implementation of this + * interface. The controller owns all orchestration (session creation, token + * exchange, token refresh, state) and only delegates the actual SDK + * presentation to `launch`. + */ +export type KycSumSubLauncher = { + /** + * Whether the underlying SDK is available in the current runtime (e.g. the + * native module is linked). When `false`, `startSumSub` fails fast. + */ + isAvailable(): boolean; + + /** + * Presents the SumSub verification flow and resolves with the SDK result. + */ + launch(params: KycSumSubLaunchParams): Promise>; +}; diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json new file mode 100644 index 00000000000..b36e81be15e --- /dev/null +++ b/packages/kyc-controller/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../geolocation-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json new file mode 100644 index 00000000000..be54252e245 --- /dev/null +++ b/packages/kyc-controller/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../geolocation-controller" }, + { "path": "../messenger" }, + { "path": "../profile-sync-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/kyc-controller/typedoc.json b/packages/kyc-controller/typedoc.json new file mode 100644 index 00000000000..c9da015dbf8 --- /dev/null +++ b/packages/kyc-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 5e805550062..05d63286108 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -145,6 +145,9 @@ { "path": "./packages/keyring-controller/tsconfig.build.json" }, + { + "path": "./packages/kyc-controller/tsconfig.build.json" + }, { "path": "./packages/local-node-utils/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 83755477993..52cdfd86d76 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -140,6 +140,9 @@ { "path": "./packages/keyring-controller" }, + { + "path": "./packages/kyc-controller" + }, { "path": "./packages/local-node-utils" }, diff --git a/yarn.lock b/yarn.lock index 8b0f0e77cc5..2a784809575 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,20 +242,7 @@ __metadata: languageName: node linkType: hard -"@asamuzakjp/css-color@npm:^3.2.0": - version: 3.2.0 - resolution: "@asamuzakjp/css-color@npm:3.2.0" - dependencies: - "@csstools/css-calc": "npm:^2.1.3" - "@csstools/css-color-parser": "npm:^3.0.9" - "@csstools/css-parser-algorithms": "npm:^3.0.4" - "@csstools/css-tokenizer": "npm:^3.0.3" - lru-cache: "npm:^10.4.3" - checksum: 10/870f661460173174fef8bfebea0799ba26566f3aa7b307e5adabb7aae84fed2da68e40080104ed0c83b43c5be632ee409e65396af13bfe948a3ef4c2c729ecd9 - languageName: node - linkType: hard - -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -273,7 +260,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -296,7 +283,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" dependencies: @@ -510,7 +497,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/parser@npm:7.29.7" dependencies: @@ -700,7 +687,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": +"@babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -799,7 +786,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": +"@babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1646,7 +1633,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.29.7": +"@babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -1672,7 +1659,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -1740,7 +1727,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4": +"@csstools/css-calc@npm:^2.1.4": version: 2.1.4 resolution: "@csstools/css-calc@npm:2.1.4" peerDependencies: @@ -1750,7 +1737,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-color-parser@npm:^3.0.9, @csstools/css-color-parser@npm:^3.1.0": +"@csstools/css-color-parser@npm:^3.1.0": version: 3.1.0 resolution: "@csstools/css-color-parser@npm:3.1.0" dependencies: @@ -1763,7 +1750,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-parser-algorithms@npm:^3.0.4, @csstools/css-parser-algorithms@npm:^3.0.5": +"@csstools/css-parser-algorithms@npm:^3.0.5": version: 3.0.5 resolution: "@csstools/css-parser-algorithms@npm:3.0.5" peerDependencies: @@ -1772,7 +1759,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-tokenizer@npm:^3.0.3, @csstools/css-tokenizer@npm:^3.0.4": +"@csstools/css-tokenizer@npm:^3.0.4": version: 3.0.4 resolution: "@csstools/css-tokenizer@npm:3.0.4" checksum: 10/eb6c84c086312f6bb8758dfe2c85addd7475b0927333c5e39a4d59fb210b9810f8c346972046f95e60a721329cffe98895abe451e51de753ad1ca7a8c24ec65f @@ -4714,205 +4701,152 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/console@npm:30.4.1" +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" dependencies: - "@jest/types": "npm:30.4.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - chalk: "npm:^4.1.2" - jest-message-util: "npm:30.4.1" - jest-util: "npm:30.4.1" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" - checksum: 10/4eb463d29654c20716f5f9cde43e5d958cb3b9234477df57da5b3814c3f1a4a0ab611a8eaf4b5abc146190a012584d7025f445f3560ed62acd843fc95c0a0e65 + checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e languageName: node linkType: hard -"@jest/core@npm:30.4.2": - version: 30.4.2 - resolution: "@jest/core@npm:30.4.2" +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" dependencies: - "@jest/console": "npm:30.4.1" - "@jest/pattern": "npm:30.4.0" - "@jest/reporters": "npm:30.4.1" - "@jest/test-result": "npm:30.4.1" - "@jest/transform": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - exit-x: "npm:^0.2.2" - fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.11" - jest-changed-files: "npm:30.4.1" - jest-config: "npm:30.4.2" - jest-haste-map: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-regex-util: "npm:30.4.0" - jest-resolve: "npm:30.4.1" - jest-resolve-dependencies: "npm:30.4.2" - jest-runner: "npm:30.4.2" - jest-runtime: "npm:30.4.2" - jest-snapshot: "npm:30.4.1" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" - jest-watcher: "npm:30.4.1" - pretty-format: "npm:30.4.1" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10/ecc695392685ab56c6df5d29d6f7927141071d8f3f75e5f7c7664f0faded1307caf5daf051074252d5ddb9546bf2bfe3b0c63ca81fe6238dc34e1bb5f8a7a261 - languageName: node - linkType: hard - -"@jest/diff-sequences@npm:30.4.0": - version: 30.4.0 - resolution: "@jest/diff-sequences@npm:30.4.0" - checksum: 10/65c27937c10a7157899dad5d176806104286f9d55464f318955a0cee98db8aed6b8f70ad4aee7133468087146422cdd391d49b1e101ec543db3283ee4eb59c06 - languageName: node - linkType: hard - -"@jest/environment-jsdom-abstract@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/environment-jsdom-abstract@npm:30.4.1" - dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/fake-timers": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - "@types/jsdom": "npm:^21.1.7" - "@types/node": "npm:*" - jest-mock: "npm:30.4.1" - jest-util: "npm:30.4.1" - peerDependencies: - canvas: ^3.0.0 - jsdom: "*" - peerDependenciesMeta: - canvas: - optional: true - checksum: 10/e51537587162d0972df0abd6f0ce0b2cf5245029035e6b61b62da075547a37cd174254c315b456447a6419b22938623036b355c063498b7876f7ea0352404fa6 + checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e languageName: node linkType: hard -"@jest/environment@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/environment@npm:30.4.1" +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" dependencies: - "@jest/fake-timers": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:30.4.1" - checksum: 10/c25946fee29604f5aa24ea059bc3cc7bc4c8cdaf26db1ed6ffa4f28e37f5193cc4e868650c807d89caff4123e44d07b58200d4cb5960ebdb7d66531509d76359 + jest-mock: "npm:^29.7.0" + checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 languageName: node linkType: hard -"@jest/expect-utils@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/expect-utils@npm:30.4.1" +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" dependencies: - "@jest/get-type": "npm:30.1.0" - checksum: 10/3f0337ec791d669cacd07594521f2da71b956712dfd0c0007253dd5e886ef640df510af1357878a80ac56f09d3db9fd68e3db66959f0fdb3add5f551dd7e0f35 + jest-get-type: "npm:^29.6.3" + checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee languageName: node linkType: hard -"@jest/expect@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/expect@npm:30.4.1" +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" dependencies: - expect: "npm:30.4.1" - jest-snapshot: "npm:30.4.1" - checksum: 10/40ae0317a3590ced7a7fd21c49e6b1af6b122e6a83822e643af83f02034dfed6485248cae08d6bcf9380039ba3824ac56db18478712c64ddf5f709ee23cf30cd + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 languageName: node linkType: hard -"@jest/fake-timers@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/fake-timers@npm:30.4.1" +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" dependencies: - "@jest/types": "npm:30.4.1" - "@sinonjs/fake-timers": "npm:^15.4.0" + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" "@types/node": "npm:*" - jest-message-util: "npm:30.4.1" - jest-mock: "npm:30.4.1" - jest-util: "npm:30.4.1" - checksum: 10/bc7aff23548395d6e7957bc24f699f921a9616f2357ab49616b0468c7b5e94e6ac4cbdd45d306f1a5d7f72e2a055294f52be3666e4c1da7c137874c5b226e1c6 - languageName: node - linkType: hard - -"@jest/get-type@npm:30.1.0": - version: 30.1.0 - resolution: "@jest/get-type@npm:30.1.0" - checksum: 10/e2a95fbb49ce2d15547db8af5602626caf9b05f62a5e583b4a2de9bd93a2bfe7175f9bbb2b8a5c3909ce261d467b6991d7265bb1d547cb60e7e97f571f361a70 - languageName: node - linkType: hard - -"@jest/globals@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/globals@npm:30.4.1" - dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/expect": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - jest-mock: "npm:30.4.1" - checksum: 10/5fe04b9c3b97f0061e4464201ee0dd674dd958843eb80542791d1c576c51f12aaa3f3b369e136d5fd3f8c716f9c9bbfbb76491a3cbc3c4efb3cc71063f909132 + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 languageName: node linkType: hard -"@jest/pattern@npm:30.4.0": - version: 30.4.0 - resolution: "@jest/pattern@npm:30.4.0" +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" dependencies: - "@types/node": "npm:*" - jest-regex-util: "npm:30.4.0" - checksum: 10/4fb1db0e586713708d2fcd79059315600978608483ef2d80e04a0a59b20b0d8de0d3f47cad950ff90bfb9ea3cb788709ee3d1eb225734e4dbf1c4b743c93d204 + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 languageName: node linkType: hard -"@jest/reporters@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/reporters@npm:30.4.1" +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" dependencies: "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:30.4.1" - "@jest/test-result": "npm:30.4.1" - "@jest/transform": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - "@jridgewell/trace-mapping": "npm:^0.3.25" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" "@types/node": "npm:*" - chalk: "npm:^4.1.2" - collect-v8-coverage: "npm:^1.0.2" - exit-x: "npm:^0.2.2" - glob: "npm:^10.5.0" - graceful-fs: "npm:^4.2.11" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" istanbul-lib-coverage: "npm:^3.0.0" istanbul-lib-instrument: "npm:^6.0.0" istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^5.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:30.4.1" - jest-util: "npm:30.4.1" - jest-worker: "npm:30.4.1" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" slash: "npm:^3.0.0" - string-length: "npm:^4.0.2" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" v8-to-istanbul: "npm:^9.0.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true - checksum: 10/e14e3717c9fe49004b6406cc53554f90954345917bb5f077d23e3a2cecde26d90493e1522d95f63b6c14ef06fb63d45b695ac6400d63b96fecc7a80d1ef83f4d - languageName: node - linkType: hard - -"@jest/schemas@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/schemas@npm:30.4.1" - dependencies: - "@sinclair/typebox": "npm:^0.34.0" - checksum: 10/86e62c8fd8fc77535085f1ede3a416430a3740f78b8f88ec7d0ee4516b22daf3326ffc1ade9d5f7839bbde923aaf1b5ac430a42ed4bb1a38edc3de5005a58f51 + checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc languageName: node linkType: hard @@ -4925,87 +4859,61 @@ __metadata: languageName: node linkType: hard -"@jest/snapshot-utils@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/snapshot-utils@npm:30.4.1" - dependencies: - "@jest/types": "npm:30.4.1" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - natural-compare: "npm:^1.4.0" - checksum: 10/8f17768702153267388b3043f358027385e591ac4668699bfce3547cb8e08ac146a074913bcddf68c0a4f7155e24a6d582d27f4592f5c3bd5f9fbc3f9182ef78 - languageName: node - linkType: hard - -"@jest/source-map@npm:30.0.1": - version: 30.0.1 - resolution: "@jest/source-map@npm:30.0.1" +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.25" - callsites: "npm:^3.1.0" - graceful-fs: "npm:^4.2.11" - checksum: 10/161b27cdf8d9d80fd99374d55222b90478864c6990514be6ebee72b7184a034224c9aceed12c476f3a48d48601bf8ed2e0c047a5a81bd907dc192ebe71365ed4 + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb languageName: node linkType: hard -"@jest/test-result@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/test-result@npm:30.4.1" +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" dependencies: - "@jest/console": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - collect-v8-coverage: "npm:^1.0.2" - checksum: 10/c420182d72cef64827981230b4c84b2de3f4312067e7baf1e3e13c501dc57f73faa09fed1a5ed1a6e96bc29f6c67ac2c14de5f973945f14853010729678cb44a + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 languageName: node linkType: hard -"@jest/test-sequencer@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/test-sequencer@npm:30.4.1" +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" dependencies: - "@jest/test-result": "npm:30.4.1" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.4.1" + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" slash: "npm:^3.0.0" - checksum: 10/d911ef0c527c402d41537aa5f9754725a58732c1c6616401454633fd45729da0b2f01b4c50322b1b789a9f2d4edf3a24aecb0b2e4ca4d873c4335894b63bc5b0 + checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 languageName: node linkType: hard -"@jest/transform@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/transform@npm:30.4.1" +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/types": "npm:30.4.1" - "@jridgewell/trace-mapping": "npm:^0.3.25" - babel-plugin-istanbul: "npm:^7.0.1" - chalk: "npm:^4.1.2" + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + babel-plugin-istanbul: "npm:^6.1.1" + chalk: "npm:^4.0.0" convert-source-map: "npm:^2.0.0" fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.4.1" - jest-regex-util: "npm:30.4.0" - jest-util: "npm:30.4.1" - pirates: "npm:^4.0.7" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pirates: "npm:^4.0.4" slash: "npm:^3.0.0" - write-file-atomic: "npm:^5.0.1" - checksum: 10/7b570451f6c26360f1b852c2281dcc4e36fe685dbc159cf5eabf83d49d6aae4569f444d38f3afb5b3b6e0b809eb41b65f3145c0cac5fee3eec9c9b178fb1f0ea - languageName: node - linkType: hard - -"@jest/types@npm:30.4.1": - version: 30.4.1 - resolution: "@jest/types@npm:30.4.1" - dependencies: - "@jest/pattern": "npm:30.4.0" - "@jest/schemas": "npm:30.4.1" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10/cc0999508613487c6d0f55661cd342ebe7cfe579fa9917534b94310204358f03f94524f70f00b4fe3c6dd2ccd0fd44657615a1b9f420ab310d68b43964bff87c + write-file-atomic: "npm:^4.0.2" + checksum: 10/30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 languageName: node linkType: hard @@ -5080,7 +4988,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -5531,12 +5439,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5568,14 +5476,14 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5609,10 +5517,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5630,10 +5538,10 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5650,12 +5558,12 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5674,11 +5582,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5694,10 +5602,10 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5720,10 +5628,10 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5741,11 +5649,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5753,7 +5661,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^11.2.0, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^11.1.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -5762,12 +5670,12 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.5.5" "@metamask/accounts-controller": "npm:^39.0.5" - "@metamask/assets-controllers": "npm:^110.0.0" + "@metamask/assets-controllers": "npm:^109.4.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^7.0.0" + "@metamask/core-backend": "npm:^6.5.0" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/keyring-internal-api": "npm:^11.0.1" @@ -5782,18 +5690,18 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5801,7 +5709,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^110.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^109.4.1, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -5821,7 +5729,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^7.0.0" + "@metamask/core-backend": "npm:^6.5.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" "@metamask/keyring-api": "npm:^23.5.0" @@ -5844,12 +5752,12 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/uuid": "npm:^8.3.0" @@ -5858,14 +5766,14 @@ __metadata: bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" multiformats: "npm:^9.9.0" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" single-call-balance-checker-abi: "npm:^1.0.0" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5923,11 +5831,11 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5966,11 +5874,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5988,15 +5896,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6011,10 +5919,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6024,7 +5932,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^78.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^77.6.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -6034,13 +5942,13 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^39.0.5" - "@metamask/assets-controller": "npm:^11.2.0" - "@metamask/assets-controllers": "npm:^110.0.0" + "@metamask/assets-controller": "npm:^11.1.0" + "@metamask/assets-controllers": "npm:^109.4.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/gas-fee-controller": "npm:^26.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.4" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" @@ -6051,18 +5959,18 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6078,9 +5986,9 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.5" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^78.0.0" + "@metamask/bridge-controller": "npm:^77.6.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/gas-fee-controller": "npm:^26.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.4" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" @@ -6088,17 +5996,17 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6124,10 +6032,10 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/eslint": "npm:^8.44.7" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6147,11 +6055,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6159,7 +6067,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@npm:^4.0.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": +"@metamask/chomp-api-service@npm:^3.1.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": version: 0.0.0-use.local resolution: "@metamask/chomp-api-service@workspace:packages/chomp-api-service" dependencies: @@ -6171,11 +6079,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6195,10 +6103,10 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6214,11 +6122,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6234,15 +6142,15 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^7.0.0" + "@metamask/core-backend": "npm:^6.5.0" "@metamask/keyring-api": "npm:^23.5.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6261,12 +6169,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6283,11 +6191,11 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.5.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6295,7 +6203,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/config-registry-controller@npm:^0.4.1, @metamask/config-registry-controller@workspace:packages/config-registry-controller": +"@metamask/config-registry-controller@workspace:packages/config-registry-controller": version: 0.0.0-use.local resolution: "@metamask/config-registry-controller@workspace:packages/config-registry-controller" dependencies: @@ -6311,12 +6219,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6332,11 +6240,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6385,7 +6293,7 @@ __metadata: "@spruceid/siwe-parser": "npm:2.1.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" @@ -6393,11 +6301,11 @@ __metadata: deepmerge: "npm:^4.2.2" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6407,7 +6315,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/core-backend@npm:^7.0.0, @metamask/core-backend@workspace:packages/core-backend": +"@metamask/core-backend@npm:^6.5.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" dependencies: @@ -6421,12 +6329,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6453,14 +6361,13 @@ __metadata: "@metamask/skills": "npm:^0.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/semver": "npm:^7" "@typescript-eslint/eslint-plugin": "npm:^8.48.0" "@typescript-eslint/parser": "npm:^8.48.0" "@yarnpkg/types": "npm:^4.0.0" - bats: "npm:^1.13.0" comment-json: "npm:^4.5.1" eslint: "npm:^9.39.1" eslint-config-prettier: "npm:^9.1.0" @@ -6473,13 +6380,14 @@ __metadata: eslint-plugin-promise: "npm:^7.1.0" execa: "npm:^5.0.0" isomorphic-fetch: "npm:^3.0.0" - jest: "npm:^30.4.2" - jest-silent-reporter: "npm:^0.6.0" + jest: "npm:^29.7.0" + jest-silent-reporter: "npm:^0.5.0" knip: "npm:^6.23.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" oxfmt: "npm:^0.44.0" prettier: "npm:^3.3.3" + prettier-2: "npm:prettier@^2.8.8" rimraf: "npm:^5.0.5" semver: "npm:^7.6.3" simple-git-hooks: "npm:^2.8.0" @@ -6532,10 +6440,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6575,13 +6483,13 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6599,15 +6507,15 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" klona: "npm:^2.0.6" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6626,10 +6534,10 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6649,11 +6557,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6673,11 +6581,11 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" punycode: "npm:^2.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6758,12 +6666,12 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/json-rpc-random-id": "npm:^1.0.1" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" json-rpc-random-id: "npm:^1.0.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6830,10 +6738,10 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" klona: "npm:^2.0.6" safe-stable-stringify: "npm:^2.4.3" tsx: "npm:^4.20.5" @@ -6867,12 +6775,12 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" @@ -7108,16 +7016,16 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/unzipper": "npm:^0.10.10" "@types/yargs": "npm:^17.0.32" "@types/yargs-parser": "npm:^21.0.3" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" minipass: "npm:^7.1.2" nock: "npm:^13.3.1" tar: "npm:^7.4.3" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7131,7 +7039,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gas-fee-controller@npm:^26.3.0, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": +"@metamask/gas-fee-controller@npm:^26.2.4, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": version: 0.0.0-use.local resolution: "@metamask/gas-fee-controller@workspace:packages/gas-fee-controller" dependencies: @@ -7147,15 +7055,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/jest-when": "npm:^2.7.3" "@types/uuid": "npm:^8.3.0" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-when: "npm:^3.7.0" + jest: "npm:^29.7.0" + jest-when: "npm:^3.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7183,13 +7091,13 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7206,10 +7114,10 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7224,10 +7132,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7250,12 +7158,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" klona: "npm:^2.0.6" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" @@ -7271,13 +7179,13 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" extension-port-stream: "npm:^3.0.0" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" readable-stream: "npm:^3.6.2" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7334,15 +7242,15 @@ __metadata: "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-node: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7438,17 +7346,48 @@ __metadata: languageName: node linkType: hard +"@metamask/kyc-controller@workspace:packages/kyc-controller": + version: 0.0.0-use.local + resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^0.1.3" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + chokidar-cli: "npm:^3.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/local-node-utils@npm:^1.0.0, @metamask/local-node-utils@workspace:packages/local-node-utils": version: 0.0.0-use.local resolution: "@metamask/local-node-utils@workspace:packages/local-node-utils" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7466,10 +7405,10 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7489,12 +7428,12 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7510,14 +7449,14 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/yargs": "npm:^17.0.32" deepmerge: "npm:^4.2.2" execa: "npm:^5.0.0" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" oxfmt: "npm:^0.44.0" prettier: "npm:^3.3.3" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" yargs: "npm:^17.7.2" @@ -7556,11 +7495,11 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7577,7 +7516,7 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-api-data-service@npm:^0.3.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": +"@metamask/money-account-api-data-service@npm:^0.2.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" dependencies: @@ -7589,11 +7528,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7612,17 +7551,17 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/money-account-api-data-service": "npm:^0.3.0" + "@metamask/money-account-api-data-service": "npm:^0.2.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7644,11 +7583,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7663,7 +7602,7 @@ __metadata: "@metamask/authenticated-user-storage": "npm:^3.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/chomp-api-service": "npm:^4.0.0" + "@metamask/chomp-api-service": "npm:^3.1.0" "@metamask/delegation-controller": "npm:^3.0.2" "@metamask/delegation-core": "npm:^2.2.1" "@metamask/delegation-deployments": "npm:^1.4.0" @@ -7672,11 +7611,11 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-node: "npm:^30.4.1" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7711,13 +7650,13 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7752,11 +7691,11 @@ __metadata: "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7781,15 +7720,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@solana/addresses": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7815,12 +7754,12 @@ __metadata: "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7839,11 +7778,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7865,11 +7804,11 @@ __metadata: "@metamask/network-enablement-controller": "npm:^5.6.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7899,7 +7838,7 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/jest-when": "npm:^2.7.3" "@types/lodash": "npm:^4.14.191" "@types/node-fetch": "npm:^2.6.12" @@ -7908,15 +7847,15 @@ __metadata: deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" - jest-when: "npm:^3.7.0" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + jest-when: "npm:^3.4.2" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" node-fetch: "npm:^2.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7932,21 +7871,20 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/config-registry-controller": "npm:^0.4.1" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-network-controller": "npm:^3.2.1" "@metamask/network-controller": "npm:^34.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7982,7 +7920,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/lodash": "npm:^4.14.191" "@types/readable-stream": "npm:^2.3.0" "@types/semver": "npm:^7" @@ -7990,13 +7928,13 @@ __metadata: contentful: "npm:^10.15.0" deepmerge: "npm:^4.2.2" firebase: "npm:^11.2.0" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" semver: "npm:^7.6.3" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8039,12 +7977,12 @@ __metadata: "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-node: "npm:^30.4.1" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8085,13 +8023,13 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8110,12 +8048,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8141,18 +8079,18 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" "@nktkas/hyperliquid": "npm:^0.33.1" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8173,18 +8111,18 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/punycode": "npm:^2.1.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" punycode: "npm:^2.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8206,7 +8144,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/node": "npm:^16.18.54" "@types/npm-which": "npm:^3" "@types/react": "npm:^19.0.0" @@ -8214,12 +8152,12 @@ __metadata: deepmerge: "npm:^4.2.2" execa: "npm:^5.0.0" glob: "npm:^13.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" npm-which: "npm:^3.0.1" prism-react-renderer: "npm:^2.4.1" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" ts-morph: "npm:^28.0.0" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" @@ -8239,12 +8177,12 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" fast-json-stable-stringify: "npm:^2.1.0" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8272,10 +8210,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8300,15 +8238,15 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8338,16 +8276,16 @@ __metadata: "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" siwe: "npm:^2.3.2" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8380,7 +8318,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^17.1.0, @metamask/ramps-controller@workspace:packages/ramps-controller": +"@metamask/ramps-controller@npm:^17.0.0, @metamask/ramps-controller@workspace:packages/ramps-controller": version: 0.0.0-use.local resolution: "@metamask/ramps-controller@workspace:packages/ramps-controller" dependencies: @@ -8391,11 +8329,11 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8413,10 +8351,10 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8432,13 +8370,13 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^5.62.16" - "@tanstack/react-query": "npm:^5.62.16" + "@tanstack/query-core": "npm:^4.43.0" + "@tanstack/react-query": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8466,11 +8404,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8510,11 +8448,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8532,7 +8470,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^10.1.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^10.0.3, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -8551,14 +8489,14 @@ __metadata: "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/elliptic": "npm:^6" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/json-stable-stringify-without-jsonify": "npm:^1.0.2" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - jest-environment-node: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8579,14 +8517,14 @@ __metadata: "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8594,7 +8532,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/sentinel-api-service@npm:^1.0.0, @metamask/sentinel-api-service@workspace:packages/sentinel-api-service": +"@metamask/sentinel-api-service@workspace:packages/sentinel-api-service": version: 0.0.0-use.local resolution: "@metamask/sentinel-api-service@workspace:packages/sentinel-api-service" dependencies: @@ -8606,11 +8544,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8630,15 +8568,15 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/signature-controller": "npm:^39.2.7" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8664,12 +8602,12 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8714,16 +8652,16 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8748,11 +8686,11 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8889,10 +8827,10 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/superstruct": "npm:^3.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8907,10 +8845,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8933,10 +8871,10 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8952,10 +8890,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8973,14 +8911,14 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.8" "@metamask/profile-sync-controller": "npm:^28.3.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9020,7 +8958,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^69.2.1, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^69.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -9037,11 +8975,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^7.0.0" + "@metamask/core-backend": "npm:^6.5.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" "@metamask/ethjs-provider-http": "npm:^0.3.0" - "@metamask/gas-fee-controller": "npm:^26.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.4" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" @@ -9051,7 +8989,7 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/node": "npm:^16.18.54" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" @@ -9060,11 +8998,11 @@ __metadata: eth-method-registry: "npm:^4.0.0" fast-json-patch: "npm:^3.1.1" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-jsdom: "npm:^30.4.1" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9083,30 +9021,29 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^11.2.0" - "@metamask/assets-controllers": "npm:^110.0.0" + "@metamask/assets-controller": "npm:^11.1.0" + "@metamask/assets-controllers": "npm:^109.4.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/gas-fee-controller": "npm:^26.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.4" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" - "@metamask/ramps-controller": "npm:^17.1.0" + "@metamask/ramps-controller": "npm:^17.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" - "@metamask/sentinel-api-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9124,23 +9061,23 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^26.3.0" + "@metamask/gas-fee-controller": "npm:^26.2.4" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/polling-controller": "npm:^16.0.8" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9201,17 +9138,17 @@ __metadata: "@metamask/storage-service": "npm:^1.0.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@metamask/wallet": "npm:^8.1.0" + "@metamask/wallet": "npm:^7.0.1" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/better-sqlite3": "npm:^7.6.13" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" better-sqlite3: "npm:^12.9.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^30.4.2" - jest-environment-node: "npm:^30.4.1" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" bin: @@ -9237,22 +9174,22 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" "@types/react": "npm:^19.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" + jest: "npm:^29.7.0" nock: "npm:^13.3.1" prism-react-renderer: "npm:^2.4.1" raw-loader: "npm:^4.0.2" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - ts-jest: "npm:^29.4.11" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/wallet@npm:^8.1.0, @metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^7.0.1, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -9264,22 +9201,21 @@ __metadata: "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/passkey-controller": "npm:^3.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/seedless-onboarding-controller": "npm:^10.1.0" + "@metamask/seedless-onboarding-controller": "npm:^10.0.3" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.2.1" + "@metamask/transaction-controller": "npm:^69.2.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^30.0.0" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^30.4.2" - ts-jest: "npm:^29.4.11" + jest: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9309,7 +9245,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^0.2.11, @napi-rs/wasm-runtime@npm:^0.2.3": +"@napi-rs/wasm-runtime@npm:^0.2.3": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" dependencies: @@ -10475,13 +10411,6 @@ __metadata: languageName: node linkType: hard -"@pkgr/core@npm:^0.2.9": - version: 0.2.9 - resolution: "@pkgr/core@npm:0.2.9" - checksum: 10/bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f - languageName: node - linkType: hard - "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -10589,7 +10518,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -10682,13 +10611,6 @@ __metadata: languageName: node linkType: hard -"@sinclair/typebox@npm:^0.34.0": - version: 0.34.48 - resolution: "@sinclair/typebox@npm:0.34.48" - checksum: 10/186eebb338255db7cfd77c2f94be0ad91816c7b5ee994c3adb95e0474ae98b769574c2b6b1f26a81613d7148ed20b11e02528f4263d8d95e3ca8dcf8faaf5306 - languageName: node - linkType: hard - "@sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -10703,7 +10625,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.0": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -10712,12 +10634,12 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^15.4.0": - version: 15.4.0 - resolution: "@sinonjs/fake-timers@npm:15.4.0" +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" dependencies: - "@sinonjs/commons": "npm:^3.0.1" - checksum: 10/3960a9fe065f38a4228c66d184eeb101e8a6af9cbfc8454dd5d45ac397201da72134048d4e808a25993494885b172dd6deecdad9949bbf4c1d3a220ef561f6cc + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e languageName: node linkType: hard @@ -11030,28 +10952,43 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:5.101.2, @tanstack/query-core@npm:^5.62.16": - version: 5.101.2 - resolution: "@tanstack/query-core@npm:5.101.2" - checksum: 10/567af5e3c21628745a08c1a5054d838597bc620dcbbeabe20cdd6ccffdcca85a8d38128f6e80829b6e04d10f79ca793a7dd8fddc8a3d6bef10c2d580ae214c7d - languageName: node - linkType: hard - -"@tanstack/query-core@npm:^4.43.0": +"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": version: 4.43.0 resolution: "@tanstack/query-core@npm:4.43.0" checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 languageName: node linkType: hard -"@tanstack/react-query@npm:^5.62.16": - version: 5.101.2 - resolution: "@tanstack/react-query@npm:5.101.2" +"@tanstack/query-core@npm:^5.62.16": + version: 5.90.20 + resolution: "@tanstack/query-core@npm:5.90.20" + checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 + languageName: node + linkType: hard + +"@tanstack/react-query@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/react-query@npm:4.43.0" dependencies: - "@tanstack/query-core": "npm:5.101.2" + "@tanstack/query-core": "npm:4.43.0" + use-sync-external-store: "npm:^1.6.0" peerDependencies: - react: ^18 || ^19 - checksum: 10/0837c176b6afb01e3632010bbd8bdfeee7f46c4a25a50ed2ba9ddddce11c34431161d80dd8e53a2f55190c2313bbfb22a84a77308e6ccf29877b4818de68c4b3 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-native: "*" + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + checksum: 10/23f9d18d130fa2a1238d8fba8bc914c67e33753b7fc3a3c7856354a9873c4cbc5d18ce24dbf6364ecf86b8ea787575e1e60998ea75baa2b9e9647ad4b9127e10 + languageName: node + linkType: hard + +"@tootallnate/once@npm:2": + version: 2.0.0 + resolution: "@tootallnate/once@npm:2.0.0" + checksum: 10/ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 languageName: node linkType: hard @@ -11167,7 +11104,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.1.14": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -11199,7 +11136,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -11369,6 +11306,15 @@ __metadata: languageName: node linkType: hard +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "npm:*" + checksum: 10/79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 + languageName: node + linkType: hard + "@types/gtag.js@npm:^0.0.20": version: 0.0.20 resolution: "@types/gtag.js@npm:0.0.20" @@ -11422,7 +11368,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10/3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 @@ -11438,7 +11384,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": +"@types/istanbul-reports@npm:^3.0.0": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -11456,24 +11402,24 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:*, @types/jest@npm:^30.0.0": - version: 30.0.0 - resolution: "@types/jest@npm:30.0.0" +"@types/jest@npm:*, @types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" dependencies: - expect: "npm:^30.0.0" - pretty-format: "npm:^30.0.0" - checksum: 10/cdeaa924c68b5233d9ff92861a89e7042df2b0f197633729bcf3a31e65bd4e9426e751c5665b5ac2de0b222b33f100a5502da22aefce3d2c62931c715e88f209 + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b languageName: node linkType: hard -"@types/jsdom@npm:^21.1.7": - version: 21.1.7 - resolution: "@types/jsdom@npm:21.1.7" +"@types/jsdom@npm:^20.0.0": + version: 20.0.1 + resolution: "@types/jsdom@npm:20.0.1" dependencies: "@types/node": "npm:*" "@types/tough-cookie": "npm:*" parse5: "npm:^7.0.0" - checksum: 10/a5ee54aec813ac928ef783f69828213af4d81325f584e1fe7573a9ae139924c40768d1d5249237e62d51b9a34ed06bde059c86c6b0248d627457ec5e5d532dfa + checksum: 10/15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 languageName: node linkType: hard @@ -11755,7 +11701,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -11831,12 +11777,12 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": - version: 17.0.35 - resolution: "@types/yargs@npm:17.0.35" +"@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.8": + version: 17.0.33 + resolution: "@types/yargs@npm:17.0.33" dependencies: "@types/yargs-parser": "npm:*" - checksum: 10/47bcd4476a4194ea11617ea71cba8a1eddf5505fc39c44336c1a08d452a0de4486aedbc13f47a017c8efbcb5a8aa358d976880663732ebcbc6dbcbbecadb0581 + checksum: 10/16f6681bf4d99fb671bf56029141ed01db2862e3db9df7fc92d8bea494359ac96a1b4b1c35a836d1e95e665fb18ad753ab2015fc0db663454e8fd4e5d5e2ef91 languageName: node linkType: hard @@ -11975,148 +11921,13 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.3.0": +"@ungap/structured-clone@npm:^1.0.0": version: 1.3.1 resolution: "@ungap/structured-clone@npm:1.3.1" checksum: 10/64df206f50aef71c176f9059c1b29e1694821419c6728c446ecf39c80a811eeef156668bf51421b676494a12fd0129ccf09a44f0c641f13c27f50d5f0db6de4e languageName: node linkType: hard -"@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" - conditions: os=android & cpu=arm - languageName: node - linkType: hard - -"@unrs/resolver-binding-android-arm64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" - conditions: os=android & cpu=arm64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-darwin-arm64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" - conditions: os=darwin & cpu=arm64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-darwin-x64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" - conditions: os=darwin & cpu=x64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-freebsd-x64@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" - conditions: os=freebsd & cpu=x64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" - conditions: os=linux & cpu=arm - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" - conditions: os=linux & cpu=arm64 & libc=glibc - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" - conditions: os=linux & cpu=arm64 & libc=musl - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" - conditions: os=linux & cpu=ppc64 & libc=glibc - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" - conditions: os=linux & cpu=riscv64 & libc=glibc - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" - conditions: os=linux & cpu=riscv64 & libc=musl - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" - conditions: os=linux & cpu=s390x & libc=glibc - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" - conditions: os=linux & cpu=x64 & libc=glibc - languageName: node - linkType: hard - -"@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" - conditions: os=linux & cpu=x64 & libc=musl - languageName: node - linkType: hard - -"@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" - dependencies: - "@napi-rs/wasm-runtime": "npm:^0.2.11" - conditions: cpu=wasm32 - languageName: node - linkType: hard - -"@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" - conditions: os=win32 & cpu=arm64 - languageName: node - linkType: hard - -"@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" - conditions: os=win32 & cpu=ia32 - languageName: node - linkType: hard - -"@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": - version: 1.11.1 - resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@vercel/stega@npm:^0.1.2": version: 0.1.2 resolution: "@vercel/stega@npm:0.1.2" @@ -12305,6 +12116,13 @@ __metadata: languageName: node linkType: hard +"abab@npm:^2.0.6": + version: 2.0.6 + resolution: "abab@npm:2.0.6" + checksum: 10/ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 + languageName: node + linkType: hard + "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -12361,6 +12179,16 @@ __metadata: languageName: node linkType: hard +"acorn-globals@npm:^7.0.0": + version: 7.0.1 + resolution: "acorn-globals@npm:7.0.1" + dependencies: + acorn: "npm:^8.1.0" + acorn-walk: "npm:^8.0.2" + checksum: 10/2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 + languageName: node + linkType: hard + "acorn-import-phases@npm:^1.0.3": version: 1.0.4 resolution: "acorn-import-phases@npm:1.0.4" @@ -12379,7 +12207,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.0": +"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.0.2": version: 8.3.5 resolution: "acorn-walk@npm:8.3.5" dependencies: @@ -12388,7 +12216,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.0, acorn@npm:^8.0.4, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0": +"acorn@npm:^8.0.0, acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.8.1": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -12425,10 +12253,21 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:^7.1.0, agent-base@npm:^7.1.1, agent-base@npm:^7.1.2": - version: 7.1.4 - resolution: "agent-base@npm:7.1.4" - checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8 +"agent-base@npm:6": + version: 6.0.2 + resolution: "agent-base@npm:6.0.2" + dependencies: + debug: "npm:4" + checksum: 10/21fb903e0917e5cb16591b4d0ef6a028a54b83ac30cd1fca58dece3d4e0990512a8723f9f83130d88a41e2af8b1f7be1386fda3ea2d181bb1a62155e75e95e23 + languageName: node + linkType: hard + +"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": + version: 7.1.1 + resolution: "agent-base@npm:7.1.1" + dependencies: + debug: "npm:^4.3.4" + checksum: 10/c478fec8f79953f118704d007a38f2a185458853f5c45579b9669372bd0e12602e88dc2ad0233077831504f7cd6fcc8251c383375bba5eaaf563b102938bda26 languageName: node linkType: hard @@ -12542,7 +12381,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.3.2": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -12560,6 +12399,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -12581,6 +12427,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -12590,7 +12445,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -12611,7 +12466,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -12780,20 +12635,20 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:30.4.1": - version: 30.4.1 - resolution: "babel-jest@npm:30.4.1" +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" dependencies: - "@jest/transform": "npm:30.4.1" - "@types/babel__core": "npm:^7.20.5" - babel-plugin-istanbul: "npm:^7.0.1" - babel-preset-jest: "npm:30.4.0" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" + "@jest/transform": "npm:^29.7.0" + "@types/babel__core": "npm:^7.1.14" + babel-plugin-istanbul: "npm:^6.1.1" + babel-preset-jest: "npm:^29.6.3" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" slash: "npm:^3.0.0" peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-0 - checksum: 10/f739152bee60b368b27676441c54e235b49bca10329bb6395b54cca5982ced1f7a5a2c504e406e1082aac3dc68ab518771c9de62cf66ffe00993ad071a58fd1b + "@babel/core": ^7.8.0 + checksum: 10/8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 languageName: node linkType: hard @@ -12819,25 +12674,28 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^7.0.1": - version: 7.0.1 - resolution: "babel-plugin-istanbul@npm:7.0.1" +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" dependencies: "@babel/helper-plugin-utils": "npm:^7.0.0" "@istanbuljs/load-nyc-config": "npm:^1.0.0" - "@istanbuljs/schema": "npm:^0.1.3" - istanbul-lib-instrument: "npm:^6.0.2" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-instrument: "npm:^5.0.4" test-exclude: "npm:^6.0.0" - checksum: 10/fe9f865f975aaa7a033de9ccb2b63fdcca7817266c5e98d3e02ac7ffd774c695093d215302796cb3770a71ef4574e7a9b298504c3c0c104cf4b48c8eda67b2a6 + checksum: 10/ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:30.4.0": - version: 30.4.0 - resolution: "babel-plugin-jest-hoist@npm:30.4.0" +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" dependencies: - "@types/babel__core": "npm:^7.20.5" - checksum: 10/112f984b3b4315f7ff15d5d17df7f5aa4b500e562c67c2eafcd9974af4369c17d50feed2f9c95cbcec32faba8ccb04f8b62828aca41a47d5fdedc532b49fbf19 + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.1.14" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 10/9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 languageName: node linkType: hard @@ -12889,9 +12747,9 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.2.0": - version: 1.2.0 - resolution: "babel-preset-current-node-syntax@npm:1.2.0" +"babel-preset-current-node-syntax@npm:^1.0.0": + version: 1.1.0 + resolution: "babel-preset-current-node-syntax@npm:1.1.0" dependencies: "@babel/plugin-syntax-async-generators": "npm:^7.8.4" "@babel/plugin-syntax-bigint": "npm:^7.8.3" @@ -12909,20 +12767,20 @@ __metadata: "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" "@babel/plugin-syntax-top-level-await": "npm:^7.14.5" peerDependencies: - "@babel/core": ^7.0.0 || ^8.0.0-0 - checksum: 10/3608fa671cfa46364ea6ec704b8fcdd7514b7b70e6ec09b1199e13ae73ed346c51d5ce2cb6d4d5b295f6a3f2cad1fdeec2308aa9e037002dd7c929194cc838ea + "@babel/core": ^7.0.0 + checksum: 10/46331111ae72b7121172fd9e6a4a7830f651ad44bf26dbbf77b3c8a60a18009411a3eacb5e72274004290c110371230272109957d5224d155436b4794ead2f1b languageName: node linkType: hard -"babel-preset-jest@npm:30.4.0": - version: 30.4.0 - resolution: "babel-preset-jest@npm:30.4.0" +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" dependencies: - babel-plugin-jest-hoist: "npm:30.4.0" - babel-preset-current-node-syntax: "npm:^1.2.0" + babel-plugin-jest-hoist: "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-beta.1 - checksum: 10/7fbdcaa1f24b2efbc1b658220df849a375858bd5e208cefcf53b116bca972b28565a0715521cc20bec41adbd20ff73b9dbbdea3634bd71f50062f0ce694a7159 + "@babel/core": ^7.0.0 + checksum: 10/aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb languageName: node linkType: hard @@ -13003,15 +12861,6 @@ __metadata: languageName: node linkType: hard -"bats@npm:^1.13.0": - version: 1.13.0 - resolution: "bats@npm:1.13.0" - bin: - bats: bin/bats - checksum: 10/cc120033073f5ed798accfc27d2f45863204d03673d985fdb5d850246df6adf8007be4678511ec206e5a47b46f15801fa612ca7d8e16ccf6afa7a009286c1627 - languageName: node - linkType: hard - "bech32@npm:1.1.4": version: 1.1.4 resolution: "bech32@npm:1.1.4" @@ -13230,11 +13079,11 @@ __metadata: linkType: hard "brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": - version: 2.1.2 - resolution: "brace-expansion@npm:2.1.2" + version: 2.1.1 + resolution: "brace-expansion@npm:2.1.1" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/0e0f9b0df1f0809e9c326470e022eeb24334ddb54c43b1ac39f1d38f3cbaeb940794de1db826f5491843ac00d7932c43f10350a65ccd51fe7d05da627152f204 + checksum: 10/4681c533dc4e6c77b3ad795b38683d297fd03c739a17bfb2a338529fa7dcf4540683a79dcd662905f4c5b0db7cfda18daafcd18dd1bbf7c3b076fe0c9c3487eb languageName: node linkType: hard @@ -13468,7 +13317,7 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0, callsites@npm:^3.1.0": +"callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 10/072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 @@ -13485,14 +13334,14 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b languageName: node linkType: hard -"camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": +"camelcase@npm:^6.2.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10/8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d @@ -13639,7 +13488,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -13700,13 +13563,6 @@ __metadata: languageName: node linkType: hard -"ci-info@npm:^4.2.0": - version: 4.4.0 - resolution: "ci-info@npm:4.4.0" - checksum: 10/dfded0c630267d89660c8abb988ac8395a382bdfefedcc03e3e2858523312c5207db777c239c34774e3fcff11f015477c19d2ac8a58ea58aa476614a2e64f434 - languageName: node - linkType: hard - "cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": version: 1.0.4 resolution: "cipher-base@npm:1.0.4" @@ -13717,20 +13573,13 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.3.1": +"cjs-module-lexer@npm:^1.0.0, cjs-module-lexer@npm:^1.3.1": version: 1.4.0 resolution: "cjs-module-lexer@npm:1.4.0" checksum: 10/b041096749792526120d8b8756929f8ef5dd4596502a0e1013f857e3027acd6091915fea77037921d70ee1a99988a100d994d3d3c2e323b04dd4c5ffd516cf13 languageName: node linkType: hard -"cjs-module-lexer@npm:^2.1.0": - version: 2.2.0 - resolution: "cjs-module-lexer@npm:2.2.0" - checksum: 10/fc8eb5c1919504366d8260a150d93c4e857740e770467dc59ca0cc34de4b66c93075559a5af65618f359187866b1be40e036f4e1a1bab2f1e06001c216415f74 - languageName: node - linkType: hard - "clean-css@npm:^5.2.2, clean-css@npm:^5.3.3, clean-css@npm:~5.3.2": version: 5.3.3 resolution: "clean-css@npm:5.3.3" @@ -13790,6 +13639,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -13854,10 +13714,19 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.2": - version: 1.0.3 - resolution: "collect-v8-coverage@npm:1.0.3" - checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 +"collect-v8-coverage@npm:^1.0.0": + version: 1.0.2 + resolution: "collect-v8-coverage@npm:1.0.2" + checksum: 10/30ea7d5c9ee51f2fdba4901d4186c5b7114a088ef98fd53eda3979da77eed96758a2cae81cc6d97e239aaea6065868cf908b24980663f7b7e96aa291b3e12fa4 + languageName: node + linkType: hard + +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 languageName: node linkType: hard @@ -13870,6 +13739,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -14255,6 +14131,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 + languageName: node + linkType: hard + "cron-parser@npm:^4.5.0": version: 4.9.0 resolution: "cron-parser@npm:4.9.0" @@ -14542,13 +14435,26 @@ __metadata: languageName: node linkType: hard -"cssstyle@npm:^4.2.1": - version: 4.6.0 - resolution: "cssstyle@npm:4.6.0" +"cssom@npm:^0.5.0": + version: 0.5.0 + resolution: "cssom@npm:0.5.0" + checksum: 10/b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 + languageName: node + linkType: hard + +"cssom@npm:~0.3.6": + version: 0.3.8 + resolution: "cssom@npm:0.3.8" + checksum: 10/49eacc88077555e419646c0ea84ddc73c97e3a346ad7cb95e22f9413a9722d8964b91d781ce21d378bd5ae058af9a745402383fa4e35e9cdfd19654b63f892a9 + languageName: node + linkType: hard + +"cssstyle@npm:^2.3.0": + version: 2.3.0 + resolution: "cssstyle@npm:2.3.0" dependencies: - "@asamuzakjp/css-color": "npm:^3.2.0" - rrweb-cssom: "npm:^0.8.0" - checksum: 10/1cb25c9d66b87adb165f978b75cdeb6f225d7e31ba30a8934666046a0be037e4e7200d359bfa79d4f1a4aef1083ea09633b81bcdb36a2f2ac888e8c73ea3a289 + cssom: "npm:~0.3.6" + checksum: 10/46f7f05a153446c4018b0454ee1464b50f606cb1803c90d203524834b7438eb52f3b173ba0891c618f380ced34ee12020675dc0052a7f1be755fe4ebc27ee977 languageName: node linkType: hard @@ -14559,13 +14465,14 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^5.0.0": - version: 5.0.0 - resolution: "data-urls@npm:5.0.0" +"data-urls@npm:^3.0.2": + version: 3.0.2 + resolution: "data-urls@npm:3.0.2" dependencies: - whatwg-mimetype: "npm:^4.0.0" - whatwg-url: "npm:^14.0.0" - checksum: 10/5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 + abab: "npm:^2.0.6" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + checksum: 10/033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 languageName: node linkType: hard @@ -14613,7 +14520,14 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + +"decimal.js@npm:^10.4.2, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 @@ -14638,7 +14552,7 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0": +"dedent@npm:^1.0.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" peerDependencies: @@ -14794,7 +14708,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -14830,6 +14744,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb + languageName: node + linkType: hard + "diff@npm:^5.0.0": version: 5.2.0 resolution: "diff@npm:5.2.0" @@ -14902,6 +14823,15 @@ __metadata: languageName: node linkType: hard +"domexception@npm:^4.0.0": + version: 4.0.0 + resolution: "domexception@npm:4.0.0" + dependencies: + webidl-conversions: "npm:^7.0.0" + checksum: 10/4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f + languageName: node + linkType: hard + "domhandler@npm:^4.0.0, domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": version: 4.3.1 resolution: "domhandler@npm:4.3.1" @@ -15042,6 +14972,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -15365,6 +15302,24 @@ __metadata: languageName: node linkType: hard +"escodegen@npm:^2.0.0": + version: 2.1.0 + resolution: "escodegen@npm:2.1.0" + dependencies: + esprima: "npm:^4.0.1" + estraverse: "npm:^5.2.0" + esutils: "npm:^2.0.2" + source-map: "npm:~0.6.1" + dependenciesMeta: + source-map: + optional: true + bin: + escodegen: bin/escodegen.js + esgenerate: bin/esgenerate.js + checksum: 10/47719a65b2888b4586e3fa93769068b275961c13089e90d5d01a96a6e8e95871b1c3893576814c8fbf08a4a31a496f37e7b2c937cf231270f4d81de012832c7c + languageName: node + linkType: hard + "eslint-compat-utils@npm:^0.5.1": version: 0.5.1 resolution: "eslint-compat-utils@npm:0.5.1" @@ -15992,10 +15947,10 @@ __metadata: languageName: node linkType: hard -"exit-x@npm:^0.2.2": - version: 0.2.2 - resolution: "exit-x@npm:0.2.2" - checksum: 10/ee043053e6c1e237adf5ad9c4faf9f085b606f64a4ff859e2b138fab63fe642711d00c9af452a9134c4c92c55f752e818bfabab78c24d345022db163f3137027 +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 languageName: node linkType: hard @@ -16006,17 +15961,16 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.4.1, expect@npm:^30.0.0": - version: 30.4.1 - resolution: "expect@npm:30.4.1" +"expect@npm:^29.0.0, expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" dependencies: - "@jest/expect-utils": "npm:30.4.1" - "@jest/get-type": "npm:30.1.0" - jest-matcher-utils: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-mock: "npm:30.4.1" - jest-util: "npm:30.4.1" - checksum: 10/f25051e5073c55369199ec3108ac01c60074bd09dad0b5a6c9fe40596438051cb52607e0e97505126422535b8d0dacab13aa29bb14e9fac71630bc710201a3f1 + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a languageName: node linkType: hard @@ -16260,7 +16214,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.2": +"fb-watchman@npm:^2.0.0": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -16370,6 +16324,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -16600,7 +16563,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -16610,7 +16573,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -16640,7 +16603,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -16770,9 +16733,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.5.0": - version: 10.5.0 - resolution: "glob@npm:10.5.0" +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": + version: 10.4.5 + resolution: "glob@npm:10.4.5" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -16782,7 +16745,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653 + checksum: 10/698dfe11828b7efd0514cd11e573eaed26b2dff611f0400907281ce3eab0c1e56143ef9b35adc7c77ecc71fba74717b510c7c223d34ca8a98ec81777b293d4ac languageName: node linkType: hard @@ -16797,7 +16760,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -16929,9 +16892,9 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.9": - version: 4.7.9 - resolution: "handlebars@npm:4.7.9" +"handlebars@npm:^4.7.8": + version: 4.7.8 + resolution: "handlebars@npm:4.7.8" dependencies: minimist: "npm:^1.2.5" neo-async: "npm:^2.6.2" @@ -16943,7 +16906,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 10/e755433d652e8a15fc02f83d7478e652359e7a4d354c4328818853ed4f8a39d4a09e1d22dad3c7213c5240864a65b3c840970b8b181745575dd957dd258f2b8d + checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 languageName: node linkType: hard @@ -17201,12 +17164,12 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^4.0.0": - version: 4.0.0 - resolution: "html-encoding-sniffer@npm:4.0.0" +"html-encoding-sniffer@npm:^3.0.0": + version: 3.0.0 + resolution: "html-encoding-sniffer@npm:3.0.0" dependencies: - whatwg-encoding: "npm:^3.1.1" - checksum: 10/e86efd493293a5671b8239bd099d42128433bb3c7b0fdc7819282ef8e118a21f5dead0ad6f358e024a4e5c84f17ebb7a9b36075220fac0a6222b207248bede6f + whatwg-encoding: "npm:^2.0.0" + checksum: 10/707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 languageName: node linkType: hard @@ -17369,7 +17332,18 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": +"http-proxy-agent@npm:^5.0.0": + version: 5.0.0 + resolution: "http-proxy-agent@npm:5.0.0" + dependencies: + "@tootallnate/once": "npm:2" + agent-base: "npm:6" + debug: "npm:4" + checksum: 10/5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 + languageName: node + linkType: hard + +"http-proxy-agent@npm:^7.0.0": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -17418,13 +17392,23 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": - version: 7.0.6 - resolution: "https-proxy-agent@npm:7.0.6" +"https-proxy-agent@npm:^5.0.1": + version: 5.0.1 + resolution: "https-proxy-agent@npm:5.0.1" + dependencies: + agent-base: "npm:6" + debug: "npm:4" + checksum: 10/f0dce7bdcac5e8eaa0be3c7368bb8836ed010fb5b6349ffb412b172a203efe8f807d9a6681319105ea1b6901e1972c7b5ea899672a7b9aad58309f766dcbe0df + languageName: node + linkType: hard + +"https-proxy-agent@npm:^7.0.1": + version: 7.0.5 + resolution: "https-proxy-agent@npm:7.0.5" dependencies: - agent-base: "npm:^7.1.2" + agent-base: "npm:^7.0.2" debug: "npm:4" - checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13 + checksum: 10/6679d46159ab3f9a5509ee80c3a3fc83fba3a920a5e18d32176c3327852c3c00ad640c0c4210a8fd70ea3c4a6d3a1b375bf01942516e7df80e2646bdc77658ab languageName: node linkType: hard @@ -17562,7 +17546,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.2.0": +"import-local@npm:^3.0.2": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -17812,6 +17796,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -17819,7 +17810,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.0.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -18070,7 +18061,20 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": "npm:^7.12.3" + "@babel/parser": "npm:^7.14.7" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^6.3.0" + checksum: 10/bbc4496c2f304d799f8ec22202ab38c010ac265c441947f075c0f7d46bd440b45c00e46017cf9053453d42182d768b1d6ed0e70a142c95ab00df9843aa5ab80e + languageName: node + linkType: hard + +"istanbul-lib-instrument@npm:^6.0.0": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" dependencies: @@ -18094,14 +18098,14 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^5.0.0": - version: 5.0.6 - resolution: "istanbul-lib-source-maps@npm:5.0.6" +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.23" debug: "npm:^4.1.1" istanbul-lib-coverage: "npm:^3.0.0" - checksum: 10/569dd0a392ee3464b1fe1accbaef5cc26de3479eacb5b91d8c67ebb7b425d39fd02247d85649c3a0e9c29b600809fa60b5af5a281a75a89c01f385b1e24823a2 + source-map: "npm:^0.6.1" + checksum: 10/5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 languageName: node linkType: hard @@ -18141,251 +18145,259 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:30.4.1": - version: 30.4.1 - resolution: "jest-changed-files@npm:30.4.1" +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" dependencies: - execa: "npm:^5.1.1" - jest-util: "npm:30.4.1" + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" p-limit: "npm:^3.1.0" - checksum: 10/e566af0d6c53115edf34fd1d9bda3b857fcc6626bd032516a480b60ec208d515558eda1e693e76ef992633d71828a4c38a3e91a236142459855483cbd03ff4c4 + checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d languageName: node linkType: hard -"jest-circus@npm:30.4.2": - version: 30.4.2 - resolution: "jest-circus@npm:30.4.2" +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/expect": "npm:30.4.1" - "@jest/test-result": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - chalk: "npm:^4.1.2" + chalk: "npm:^4.0.0" co: "npm:^4.6.0" - dedent: "npm:^1.6.0" - is-generator-fn: "npm:^2.1.0" - jest-each: "npm:30.4.1" - jest-matcher-utils: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-runtime: "npm:30.4.2" - jest-snapshot: "npm:30.4.1" - jest-util: "npm:30.4.1" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" p-limit: "npm:^3.1.0" - pretty-format: "npm:30.4.1" - pure-rand: "npm:^7.0.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10/b210db2cd3ab595c6053deee4c11e7eb5ea293b9af16fc87021288125dd42e8d13fdc77be21eca71f1636c6fe104edc26e32d6e707168763e97b0d1718c11203 + stack-utils: "npm:^2.0.3" + checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d languageName: node linkType: hard -"jest-cli@npm:30.4.2": - version: 30.4.2 - resolution: "jest-cli@npm:30.4.2" +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" dependencies: - "@jest/core": "npm:30.4.2" - "@jest/test-result": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - chalk: "npm:^4.1.2" - exit-x: "npm:^0.2.2" - import-local: "npm:^3.2.0" - jest-config: "npm:30.4.2" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" - yargs: "npm:^17.7.2" + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true bin: - jest: ./bin/jest.js - checksum: 10/dd33f8ee6500298639d5ec4d5f641306d9876fa182042ee40e9c63c59bdf9a82dc46da5bf38fae0783f6ac874df7f7f099006b263022954cf494f8468dfe583c + jest: bin/jest.js + checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 languageName: node linkType: hard -"jest-config@npm:30.4.2": - version: 30.4.2 - resolution: "jest-config@npm:30.4.2" +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" dependencies: - "@babel/core": "npm:^7.27.4" - "@jest/get-type": "npm:30.1.0" - "@jest/pattern": "npm:30.4.0" - "@jest/test-sequencer": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - babel-jest: "npm:30.4.1" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - deepmerge: "npm:^4.3.1" - glob: "npm:^10.5.0" - graceful-fs: "npm:^4.2.11" - jest-circus: "npm:30.4.2" - jest-docblock: "npm:30.4.0" - jest-environment-node: "npm:30.4.1" - jest-regex-util: "npm:30.4.0" - jest-resolve: "npm:30.4.1" - jest-runner: "npm:30.4.2" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" parse-json: "npm:^5.2.0" - pretty-format: "npm:30.4.1" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" strip-json-comments: "npm:^3.1.1" peerDependencies: "@types/node": "*" - esbuild-register: ">=3.4.0" ts-node: ">=9.0.0" peerDependenciesMeta: "@types/node": optional: true - esbuild-register: - optional: true ts-node: optional: true - checksum: 10/d37d923a5b3e815b73bbefe09c2f7fb092d6933f5f4ec0555fd9cacf97c58e8adb73fc4780a99e6a49e798444b1bb0097c9d5232feb1b8ffa31af589f9c02d9a + checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b languageName: node linkType: hard -"jest-diff@npm:30.4.1": - version: 30.4.1 - resolution: "jest-diff@npm:30.4.1" +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" dependencies: - "@jest/diff-sequences": "npm:30.4.0" - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - pretty-format: "npm:30.4.1" - checksum: 10/594212df96bf101170afdb7eebd188d6d7d27241cbdd18b61d95f1142a3c94ae3b270377d15e719fb3c5efe4458d32acba8ad13dd6230dd7d6917a9eebb32625 + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 languageName: node linkType: hard -"jest-docblock@npm:30.4.0": - version: 30.4.0 - resolution: "jest-docblock@npm:30.4.0" +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" dependencies: - detect-newline: "npm:^3.1.0" - checksum: 10/0ee25351ef941e832e53d10e74f34b38941b8f5fe750584661f6c5a115771818b081b8e39830c49d152fd361af81c7800c2d3a3c3a0e2dc742f7bfdbb6b1baaa + detect-newline: "npm:^3.0.0" + checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d languageName: node linkType: hard -"jest-each@npm:30.4.1": - version: 30.4.1 - resolution: "jest-each@npm:30.4.1" +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" dependencies: - "@jest/get-type": "npm:30.1.0" - "@jest/types": "npm:30.4.1" - chalk: "npm:^4.1.2" - jest-util: "npm:30.4.1" - pretty-format: "npm:30.4.1" - checksum: 10/077365c3fd0dc0d74aaa180fe4e3728533685413db58e7a30c8502d19a60a0b08259825d8aa36c569c8175a9d0cf901dd69c0a4eb63567c22c07bd0b566ddf89 + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda languageName: node linkType: hard -"jest-environment-jsdom@npm:^30.4.1": - version: 30.4.1 - resolution: "jest-environment-jsdom@npm:30.4.1" +"jest-environment-jsdom@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-jsdom@npm:29.7.0" dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/environment-jsdom-abstract": "npm:30.4.1" - jsdom: "npm:^26.1.0" + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/jsdom": "npm:^20.0.0" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jsdom: "npm:^20.0.0" peerDependencies: - canvas: ^3.0.0 + canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/2ed0860e257d404622306823f18c15f0fe881fc1d16e1245434b54eb690fa0784cf54cd7eef74b2e9373e1699b1726a0fb69d60ae388cc78eb36968fe3fd0bf6 + checksum: 10/23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 languageName: node linkType: hard -"jest-environment-node@npm:30.4.1, jest-environment-node@npm:^30.4.1": - version: 30.4.1 - resolution: "jest-environment-node@npm:30.4.1" +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/fake-timers": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-mock: "npm:30.4.1" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" - checksum: 10/b93157061c6fa4b62808741bdda5fbc0372a9d2a3db844f0ffb819ed104b3b5c6ff73375d9f57c0d8485a0ad6aed07d4cfbb98aca985c38e1a29f64096b940bc + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 + languageName: node + linkType: hard + +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10/88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 languageName: node linkType: hard -"jest-haste-map@npm:30.4.1": - version: 30.4.1 - resolution: "jest-haste-map@npm:30.4.1" +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" dependencies: - "@jest/types": "npm:30.4.1" + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" "@types/node": "npm:*" - anymatch: "npm:^3.1.3" - fb-watchman: "npm:^2.0.2" - fsevents: "npm:^2.3.3" - graceful-fs: "npm:^4.2.11" - jest-regex-util: "npm:30.4.0" - jest-util: "npm:30.4.1" - jest-worker: "npm:30.4.1" - picomatch: "npm:^4.0.3" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.3.2" + graceful-fs: "npm:^4.2.9" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + micromatch: "npm:^4.0.4" walker: "npm:^1.0.8" dependenciesMeta: fsevents: optional: true - checksum: 10/d26404c7258d03fa423604191bca39707438ca1e62a9a471c92fcd468fa386cbdce2c50b3834fb830b25836e3eee34e3070d22b016b42f0ab626c157f5726eeb + checksum: 10/8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 languageName: node linkType: hard -"jest-leak-detector@npm:30.4.1": - version: 30.4.1 - resolution: "jest-leak-detector@npm:30.4.1" +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" dependencies: - "@jest/get-type": "npm:30.1.0" - pretty-format: "npm:30.4.1" - checksum: 10/8c0945d1c73f6a2abde8660f7fc5693b344cd1f5fd66153c0c2d13007f8429486eb99ecf15ac68d3d4410534fd0fad1ed430c32a641cdac66e021dbd33204c9a + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 languageName: node linkType: hard -"jest-matcher-utils@npm:30.4.1": - version: 30.4.1 - resolution: "jest-matcher-utils@npm:30.4.1" +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" dependencies: - "@jest/get-type": "npm:30.1.0" - chalk: "npm:^4.1.2" - jest-diff: "npm:30.4.1" - pretty-format: "npm:30.4.1" - checksum: 10/4da6e5c7fe5903fae7394233ea4b892567fb027065670c03096d01be0b389f858055c5ade20d59e82fedec6f3287e6f1720de526cd9a9ad3495432320adb9194 + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 languageName: node linkType: hard -"jest-message-util@npm:30.4.1": - version: 30.4.1 - resolution: "jest-message-util@npm:30.4.1" +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" dependencies: - "@babel/code-frame": "npm:^7.27.1" - "@jest/types": "npm:30.4.1" - "@types/stack-utils": "npm:^2.0.3" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - jest-util: "npm:30.4.1" - picomatch: "npm:^4.0.3" - pretty-format: "npm:30.4.1" + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.6" - checksum: 10/f83894efa37aa9c61c0a559b1027ecdb0d0cd8afd3e8ea74e797c707d58daea814e72f04b6db0bb6a148c12ae203e9c6e6c5544832ca5fae286c4f80c18ddc3f + stack-utils: "npm:^2.0.3" + checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 languageName: node linkType: hard -"jest-mock@npm:30.4.1": - version: 30.4.1 - resolution: "jest-mock@npm:30.4.1" +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" dependencies: - "@jest/types": "npm:30.4.1" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - jest-util: "npm:30.4.1" - checksum: 10/8d0c2794130217b9030b888ce380fe57d82388eec19351bd666440ba46f1e24a7e2bdf42cbe9bcfda2b881d4c0ea09db3c80131b9ab788fb5224af2a1339b422 + jest-util: "npm:^29.7.0" + checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.3": +"jest-pnp-resolver@npm:^1.2.2": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -18397,149 +18409,134 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:30.4.0": - version: 30.4.0 - resolution: "jest-regex-util@npm:30.4.0" - checksum: 10/8664fcc1d07c8236a3bd012c0f06ae9d14d96e758b32ee340a3a7c4c326d0b5052d8c4ae4f4c4184f08bf78723d905352f22923647df9658ace3604f03bf074f +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 10/0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a languageName: node linkType: hard -"jest-resolve-dependencies@npm:30.4.2": - version: 30.4.2 - resolution: "jest-resolve-dependencies@npm:30.4.2" +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" dependencies: - jest-regex-util: "npm:30.4.0" - jest-snapshot: "npm:30.4.1" - checksum: 10/ec7e27d1abf67dfe9ec1a2887b5fa883e1e6ca38eb45506a82f93e29d8df62c18cb40ec07af43fe0fa65d568051a878b6649745f6c032d8962a8dad6323763ad + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 languageName: node linkType: hard -"jest-resolve@npm:30.4.1": - version: 30.4.1 - resolution: "jest-resolve@npm:30.4.1" +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" dependencies: - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.4.1" - jest-pnp-resolver: "npm:^1.2.3" - jest-util: "npm:30.4.1" - jest-validate: "npm:30.4.1" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" slash: "npm:^3.0.0" - unrs-resolver: "npm:^1.7.11" - checksum: 10/197ca741df92c1006c2367142b5e44827d995f062e94a923f574e87ce04f966634851bb31f54ea377ca163a8362613947cd2311abf8a5712fe879b1ac15f662f + checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 languageName: node linkType: hard -"jest-runner@npm:30.4.2": - version: 30.4.2 - resolution: "jest-runner@npm:30.4.2" +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" dependencies: - "@jest/console": "npm:30.4.1" - "@jest/environment": "npm:30.4.1" - "@jest/test-result": "npm:30.4.1" - "@jest/transform": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - chalk: "npm:^4.1.2" + chalk: "npm:^4.0.0" emittery: "npm:^0.13.1" - exit-x: "npm:^0.2.2" - graceful-fs: "npm:^4.2.11" - jest-docblock: "npm:30.4.0" - jest-environment-node: "npm:30.4.1" - jest-haste-map: "npm:30.4.1" - jest-leak-detector: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-resolve: "npm:30.4.1" - jest-runtime: "npm:30.4.2" - jest-util: "npm:30.4.1" - jest-watcher: "npm:30.4.1" - jest-worker: "npm:30.4.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" p-limit: "npm:^3.1.0" source-map-support: "npm:0.5.13" - checksum: 10/3ed8ee70019f1f5a63faaf07a15e522ec878601dc6eccbde7eb4d0529e4d1a7314e7041160075458257e3103f41d6e9bbb2df8698806805ae2768a7e90228103 + checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e languageName: node linkType: hard -"jest-runtime@npm:30.4.2": - version: 30.4.2 - resolution: "jest-runtime@npm:30.4.2" - dependencies: - "@jest/environment": "npm:30.4.1" - "@jest/fake-timers": "npm:30.4.1" - "@jest/globals": "npm:30.4.1" - "@jest/source-map": "npm:30.0.1" - "@jest/test-result": "npm:30.4.1" - "@jest/transform": "npm:30.4.1" - "@jest/types": "npm:30.4.1" +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - chalk: "npm:^4.1.2" - cjs-module-lexer: "npm:^2.1.0" - collect-v8-coverage: "npm:^1.0.2" - glob: "npm:^10.5.0" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-mock: "npm:30.4.1" - jest-regex-util: "npm:30.4.0" - jest-resolve: "npm:30.4.1" - jest-snapshot: "npm:30.4.1" - jest-util: "npm:30.4.1" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" slash: "npm:^3.0.0" strip-bom: "npm:^4.0.0" - checksum: 10/bc2612a17650e7187d2c778aa66fc07926f828eeb3a445e47ffa757f65a4fb29628a43c6e792196b0a3a06d099b0405b6654a2c314fc5092013690d01483fd75 + checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 languageName: node linkType: hard -"jest-silent-reporter@npm:^0.6.0": - version: 0.6.0 - resolution: "jest-silent-reporter@npm:0.6.0" +"jest-silent-reporter@npm:^0.5.0": + version: 0.5.0 + resolution: "jest-silent-reporter@npm:0.5.0" dependencies: chalk: "npm:^4.0.0" jest-util: "npm:^26.0.0" - checksum: 10/443e0abaf5a6dc8c17da1e8495b7a55f813224adc39b6d1954bf49ff7fe70533b1020571453180dbb8388ace87f8e1dfc79610a4554bb93334f6c4154231c292 + checksum: 10/54853eb1fd342eb074ff97bbd2f177c3425e635deb008ce1a5feede398930b6472549797342e3ea536f8e2e538d8718d6f281f3d44dec2ae4d32f61da0eb547e languageName: node linkType: hard -"jest-snapshot@npm:30.4.1": - version: 30.4.1 - resolution: "jest-snapshot@npm:30.4.1" - dependencies: - "@babel/core": "npm:^7.27.4" - "@babel/generator": "npm:^7.27.5" - "@babel/plugin-syntax-jsx": "npm:^7.27.1" - "@babel/plugin-syntax-typescript": "npm:^7.27.1" - "@babel/types": "npm:^7.27.3" - "@jest/expect-utils": "npm:30.4.1" - "@jest/get-type": "npm:30.1.0" - "@jest/snapshot-utils": "npm:30.4.1" - "@jest/transform": "npm:30.4.1" - "@jest/types": "npm:30.4.1" - babel-preset-current-node-syntax: "npm:^1.2.0" - chalk: "npm:^4.1.2" - expect: "npm:30.4.1" - graceful-fs: "npm:^4.2.11" - jest-diff: "npm:30.4.1" - jest-matcher-utils: "npm:30.4.1" - jest-message-util: "npm:30.4.1" - jest-util: "npm:30.4.1" - pretty-format: "npm:30.4.1" - semver: "npm:^7.7.2" - synckit: "npm:^0.11.8" - checksum: 10/6135108d3e0e9fb93ed10fd9ad91d8dbe56f90a9ea84c32a0b551518f8c71f363299dcc301717f3ed82cfe2a276d7993d2b3ccfabea3e8020d49ae8b0f9b6cd8 - languageName: node - linkType: hard - -"jest-util@npm:30.4.1": - version: 30.4.1 - resolution: "jest-util@npm:30.4.1" - dependencies: - "@jest/types": "npm:30.4.1" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.3" - checksum: 10/603093e12076906afcf28be514d5b7ac4e3c0e26997b0047614cf2a308b65d773137304a1fb011d747517e881aeed067f6606b9937f5b838d67f6e5734b49ebe +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 languageName: node linkType: hard @@ -18571,55 +18568,42 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:30.4.1": - version: 30.4.1 - resolution: "jest-validate@npm:30.4.1" +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" dependencies: - "@jest/get-type": "npm:30.1.0" - "@jest/types": "npm:30.4.1" - camelcase: "npm:^6.3.0" - chalk: "npm:^4.1.2" + "@jest/types": "npm:^29.6.3" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" leven: "npm:^3.1.0" - pretty-format: "npm:30.4.1" - checksum: 10/527fe8ad02df9a4f7f467ecc4e3ac2a37a27b7b30345e7bc3cb9c2ead33fbc8ed1290c0827baa06471281012c38abb96cb268af274a0a2350548e50db20a434f + pretty-format: "npm:^29.7.0" + checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 languageName: node linkType: hard -"jest-watcher@npm:30.4.1": - version: 30.4.1 - resolution: "jest-watcher@npm:30.4.1" +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" dependencies: - "@jest/test-result": "npm:30.4.1" - "@jest/types": "npm:30.4.1" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" emittery: "npm:^0.13.1" - jest-util: "npm:30.4.1" - string-length: "npm:^4.0.2" - checksum: 10/05350ed3d5643e87e22cc5faee14f912dfc06ba63d56944006d9837f2070ed509a1d124c7e7be3e3a9a6a382bd31d491146da6fda4483acd4b8292091888e9bd + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 languageName: node linkType: hard -"jest-when@npm:^3.7.0": - version: 3.7.0 - resolution: "jest-when@npm:3.7.0" +"jest-when@npm:^3.4.2": + version: 3.6.0 + resolution: "jest-when@npm:3.6.0" peerDependencies: jest: ">= 25" - checksum: 10/b5b88d077ed467aab220c71c885dbc5f448604f06e68f761ce9f479c99bb74e0dbf553d1cc980751d88401b034a578e1c44eec5e4095743b7586f02bb7c6313d - languageName: node - linkType: hard - -"jest-worker@npm:30.4.1": - version: 30.4.1 - resolution: "jest-worker@npm:30.4.1" - dependencies: - "@types/node": "npm:*" - "@ungap/structured-clone": "npm:^1.3.0" - jest-util: "npm:30.4.1" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.1.1" - checksum: 10/ff6af73c9097fc07e90490d3e1e354c702390ef66f7f40054a15dd6d56809a25634179969ff80bde782a6c645f49fa48bf3aacfe7d05af7315c48020f9b2b1cd + checksum: 10/0cb92738ccfa5711a685107f4437f18aefbe3cda120c912a9d49b612eeef03a910481ab40fe753fd42c4e617ffbb3d84c6bd66a76d963dac7f1ad9e9e5059359 languageName: node linkType: hard @@ -18634,7 +18618,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^29.4.3": +"jest-worker@npm:^29.4.3, jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: @@ -18646,22 +18630,22 @@ __metadata: languageName: node linkType: hard -"jest@npm:^30.4.2": - version: 30.4.2 - resolution: "jest@npm:30.4.2" +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" dependencies: - "@jest/core": "npm:30.4.2" - "@jest/types": "npm:30.4.1" - import-local: "npm:^3.2.0" - jest-cli: "npm:30.4.2" + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" peerDependencies: node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 peerDependenciesMeta: node-notifier: optional: true bin: - jest: ./bin/jest.js - checksum: 10/3690f4009a46781b480fbd4c8c60dd910a3cf8af76626f29971003c28eb17dfa322e86f3203e3da168edcc9f7ba973f3b1fbf15ca12393cc5f86bc61f3289fef + jest: bin/jest.js + checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a languageName: node linkType: hard @@ -18768,36 +18752,42 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^26.1.0": - version: 26.1.0 - resolution: "jsdom@npm:26.1.0" +"jsdom@npm:^20.0.0": + version: 20.0.3 + resolution: "jsdom@npm:20.0.3" dependencies: - cssstyle: "npm:^4.2.1" - data-urls: "npm:^5.0.0" - decimal.js: "npm:^10.5.0" - html-encoding-sniffer: "npm:^4.0.0" - http-proxy-agent: "npm:^7.0.2" - https-proxy-agent: "npm:^7.0.6" + abab: "npm:^2.0.6" + acorn: "npm:^8.8.1" + acorn-globals: "npm:^7.0.0" + cssom: "npm:^0.5.0" + cssstyle: "npm:^2.3.0" + data-urls: "npm:^3.0.2" + decimal.js: "npm:^10.4.2" + domexception: "npm:^4.0.0" + escodegen: "npm:^2.0.0" + form-data: "npm:^4.0.0" + html-encoding-sniffer: "npm:^3.0.0" + http-proxy-agent: "npm:^5.0.0" + https-proxy-agent: "npm:^5.0.1" is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.16" - parse5: "npm:^7.2.1" - rrweb-cssom: "npm:^0.8.0" + nwsapi: "npm:^2.2.2" + parse5: "npm:^7.1.1" saxes: "npm:^6.0.0" symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^5.1.1" - w3c-xmlserializer: "npm:^5.0.0" + tough-cookie: "npm:^4.1.2" + w3c-xmlserializer: "npm:^4.0.0" webidl-conversions: "npm:^7.0.0" - whatwg-encoding: "npm:^3.1.1" - whatwg-mimetype: "npm:^4.0.0" - whatwg-url: "npm:^14.1.1" - ws: "npm:^8.18.0" - xml-name-validator: "npm:^5.0.0" + whatwg-encoding: "npm:^2.0.0" + whatwg-mimetype: "npm:^3.0.0" + whatwg-url: "npm:^11.0.0" + ws: "npm:^8.11.0" + xml-name-validator: "npm:^4.0.0" peerDependencies: - canvas: ^3.0.0 + canvas: ^2.5.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/39d78c4889cac20826393400dce1faed1666e9244fe0c8342a8f08c315375878e6be7fcfe339a33d6ff1a083bfe9e71b16d56ecf4d9a87db2da8c795925ea8c1 + checksum: 10/a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 languageName: node linkType: hard @@ -19054,6 +19044,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -19130,6 +19130,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -19192,7 +19199,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10/e6e90267360476720fa8e83cc168aa2bf0311f3f2eea20a6ba78b90a885ae72071d9db132f40fda4129c803e7dcec3a6b6a6fbb44ca90b081630b810b5d6a41a @@ -20170,7 +20177,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -20522,15 +20529,6 @@ __metadata: languageName: node linkType: hard -"napi-postinstall@npm:^0.3.0": - version: 0.3.4 - resolution: "napi-postinstall@npm:0.3.4" - bin: - napi-postinstall: lib/cli.js - checksum: 10/5541381508f9e1051ff3518701c7130ebac779abb3a1ffe9391fcc3cab4cc0569b0ba0952357db3f6b12909c3bb508359a7a60261ffd795feebbdab967175832 - languageName: node - linkType: hard - "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -20834,7 +20832,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.16": +"nwsapi@npm:^2.2.2": version: 2.2.23 resolution: "nwsapi@npm:2.2.23" checksum: 10/aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa @@ -21212,7 +21210,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -21239,6 +21237,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -21420,7 +21427,7 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0, parse5@npm:^7.2.1, parse5@npm:^7.3.0": +"parse5@npm:^7.0.0, parse5@npm:^7.1.1, parse5@npm:^7.3.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" dependencies: @@ -21453,6 +21460,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -21586,10 +21600,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": - version: 4.0.5 - resolution: "picomatch@npm:4.0.5" - checksum: 10/8bad770af9dcdb7f94ad1a893adcbe08a97d75a18872f8fed37161d3124217471c402b3929f051532f795f4ff2e53dcebb1644df4c1a5f3a9c476fef3b9f8c51 +"picomatch@npm:^4.0.4": + version: 4.0.4 + resolution: "picomatch@npm:4.0.4" + checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce languageName: node linkType: hard @@ -21600,10 +21614,10 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": - version: 4.0.7 - resolution: "pirates@npm:4.0.7" - checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 +"pirates@npm:^4.0.4": + version: 4.0.6 + resolution: "pirates@npm:4.0.6" + checksum: 10/d02dda76f4fec1cbdf395c36c11cf26f76a644f9f9a1bfa84d3167d0d3154d5289aacc72677aa20d599bb4a6937a471de1b65c995e2aea2d8687cbcd7e43ea5f languageName: node linkType: hard @@ -22500,6 +22514,15 @@ __metadata: languageName: node linkType: hard +"prettier-2@npm:prettier@^2.8.8": + version: 2.8.8 + resolution: "prettier@npm:2.8.8" + bin: + prettier: bin-prettier.js + checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 + languageName: node + linkType: hard + "prettier-linter-helpers@npm:^1.0.0": version: 1.0.0 resolution: "prettier-linter-helpers@npm:1.0.0" @@ -22528,15 +22551,14 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0": - version: 30.4.1 - resolution: "pretty-format@npm:30.4.1" +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" dependencies: - "@jest/schemas": "npm:30.4.1" - ansi-styles: "npm:^5.2.0" - react-is-18: "npm:react-is@^18.3.1" - react-is-19: "npm:react-is@^19.2.5" - checksum: 10/60311ef47a646eeaec0432efe66290cb6f0d2eccb123a28ad4ab6d7e53087bc62db91cfd54c3cc00c89d6875aefb2bf6264381b6c9411ce6bff3d6aa8280abad + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10/dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb languageName: node linkType: hard @@ -22614,7 +22636,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -22693,6 +22715,15 @@ __metadata: languageName: node linkType: hard +"psl@npm:^1.1.33": + version: 1.15.0 + resolution: "psl@npm:1.15.0" + dependencies: + punycode: "npm:^2.3.1" + checksum: 10/5e7467eb5196eb7900d156783d12907d445c0122f76c73203ce96b148a6ccf8c5450cc805887ffada38ff92d634afcf33720c24053cb01d5b6598d1c913c5caf + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.4 resolution: "pump@npm:3.0.4" @@ -22726,10 +22757,10 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^7.0.0": - version: 7.0.1 - resolution: "pure-rand@npm:7.0.1" - checksum: 10/c61a576fda5032ec9763ecb000da4a8f19263b9e2f9ae9aa2759c8fbd9dc6b192b2ce78391ebd41abb394a5fedb7bcc4b03c9e6141ac8ab20882dd5717698b80 +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 languageName: node linkType: hard @@ -22758,6 +22789,13 @@ __metadata: languageName: node linkType: hard +"querystringify@npm:^2.1.1": + version: 2.2.0 + resolution: "querystringify@npm:2.2.0" + checksum: 10/46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 + languageName: node + linkType: hard + "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -22874,20 +22912,6 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 - languageName: node - linkType: hard - -"react-is-19@npm:react-is@^19.2.5": - version: 19.2.7 - resolution: "react-is@npm:19.2.7" - checksum: 10/ae0d3ae7638aa2fa2a82a78a817daf2806e57fa0aef357d07e1e8d1e9a301902e5967168e9334b5816db0df8fa980a725cd57569ba5a9e6e76a71e117b18be04 - languageName: node - linkType: hard - "react-is@npm:^16.13.1, react-is@npm:^16.6.0, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -22895,6 +22919,13 @@ __metadata: languageName: node linkType: hard +"react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + "react-json-view-lite@npm:^2.3.0": version: 2.5.0 resolution: "react-json-view-lite@npm:2.5.0" @@ -23343,6 +23374,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -23401,6 +23439,13 @@ __metadata: languageName: node linkType: hard +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a + languageName: node + linkType: hard + "resolve@npm:1.22.8": version: 1.22.8 resolution: "resolve@npm:1.22.8" @@ -23414,7 +23459,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.22.11, resolve@npm:^1.22.4": +"resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -23441,7 +23486,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": +"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -23524,13 +23569,6 @@ __metadata: languageName: node linkType: hard -"rrweb-cssom@npm:^0.8.0": - version: 0.8.0 - resolution: "rrweb-cssom@npm:0.8.0" - checksum: 10/07521ee36fb6569c17906afad1ac7ff8f099d49ade9249e190693ac36cdf27f88d9acf0cc66978935d5d0a23fca105643d7e9125b9a9d91ed9db9e02d31d7d80 - languageName: node - linkType: hard - "rtlcss@npm:^4.1.0": version: 4.3.0 resolution: "rtlcss@npm:4.3.0" @@ -23713,7 +23751,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -23722,12 +23760,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1": - version: 7.8.5 - resolution: "semver@npm:7.8.5" +"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.3, semver@npm:^7.8.1": + version: 7.8.2 + resolution: "semver@npm:7.8.2" bin: semver: bin/semver.js - checksum: 10/9b01d2ff11e6e4a4539b7ca3c5f280c8704cb397a28504469f2ed4f00ad2194748d756647362a9712fff30984d15772ab7f083108c2fb508e2096ae9e708f22c + checksum: 10/52221d8f1cadacda3cc3f0a2e7f7146e0442c7f4219acb25970bed055f5d0a6afbba5f22e293b078c2e93fca3dce0a08b088485e8b75d32a165f16c3627091c8 languageName: node linkType: hard @@ -23814,6 +23852,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -23967,7 +24012,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -24181,7 +24226,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0, source-map@npm:~0.6.1": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10/59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff @@ -24310,7 +24355,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.3": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -24355,7 +24400,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.2": +"string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -24376,6 +24421,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -24435,6 +24491,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -24548,7 +24613,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8, supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": +"supports-color@npm:^8, supports-color@npm:^8.0.0": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -24595,15 +24660,6 @@ __metadata: languageName: node linkType: hard -"synckit@npm:^0.11.8": - version: 0.11.12 - resolution: "synckit@npm:0.11.12" - dependencies: - "@pkgr/core": "npm:^0.2.9" - checksum: 10/2f51978bfed81aaf0b093f596709a72c49b17909020f42b43c5549f9c0fe18b1fe29f82e41ef771172d729b32e9ce82900a85d2b87fa14d59f886d4df8d7a329 - languageName: node - linkType: hard - "synckit@npm:^0.9.1": version: 0.9.2 resolution: "synckit@npm:0.9.2" @@ -24812,24 +24868,6 @@ __metadata: languageName: node linkType: hard -"tldts-core@npm:^6.1.86": - version: 6.1.86 - resolution: "tldts-core@npm:6.1.86" - checksum: 10/cb5dff9cc15661ac773a2099e98c99a5cb3cebc35909c23cc4261ff7992032c7501995ae995de3574dbbf3431e59c47496534d52f5e96abcb231f0e72144c020 - languageName: node - linkType: hard - -"tldts@npm:^6.1.32": - version: 6.1.86 - resolution: "tldts@npm:6.1.86" - dependencies: - tldts-core: "npm:^6.1.86" - bin: - tldts: bin/cli.js - checksum: 10/f7e66824e44479ccdda55ea556af14ce61c4d27708be403e3f90631defde49f82a580e1ca07187cc7e3b349e257a30c2808a22903f3a0548e136ebb609ccc109 - languageName: node - linkType: hard - "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -24860,21 +24898,24 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^5.1.1": - version: 5.1.2 - resolution: "tough-cookie@npm:5.1.2" +"tough-cookie@npm:^4.1.2": + version: 4.1.4 + resolution: "tough-cookie@npm:4.1.4" dependencies: - tldts: "npm:^6.1.32" - checksum: 10/de430e6e6d34b794137e05b8ac2aa6b74ebbe6cdceb4126f168cf1e76101162a4b2e0e7587c3b70e728bd8654fc39958b2035be7619ee6f08e7257610ba4cd04 + psl: "npm:^1.1.33" + punycode: "npm:^2.1.1" + universalify: "npm:^0.2.0" + url-parse: "npm:^1.5.3" + checksum: 10/75663f4e2cd085f16af0b217e4218772adf0617fb3227171102618a54ce0187a164e505d61f773ed7d65988f8ff8a8f935d381f87da981752c1171b076b4afac languageName: node linkType: hard -"tr46@npm:^5.1.0": - version: 5.1.1 - resolution: "tr46@npm:5.1.1" +"tr46@npm:^3.0.0": + version: 3.0.0 + resolution: "tr46@npm:3.0.0" dependencies: - punycode: "npm:^2.3.1" - checksum: 10/833a0e1044574da5790148fd17866d4ddaea89e022de50279967bcd6b28b4ce0d30d59eb3acf9702b60918975b3bad481400337e3a2e6326cffa5c77b874753d + punycode: "npm:^2.1.1" + checksum: 10/b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 languageName: node linkType: hard @@ -24917,17 +24958,17 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.4.11": - version: 29.4.11 - resolution: "ts-jest@npm:29.4.11" +"ts-jest@npm:^29.2.5": + version: 29.4.6 + resolution: "ts-jest@npm:29.4.6" dependencies: bs-logger: "npm:^0.2.6" fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.9" + handlebars: "npm:^4.7.8" json5: "npm:^2.2.3" lodash.memoize: "npm:^4.1.2" make-error: "npm:^1.3.6" - semver: "npm:^7.8.0" + semver: "npm:^7.7.3" type-fest: "npm:^4.41.0" yargs-parser: "npm:^21.1.1" peerDependencies: @@ -24937,7 +24978,7 @@ __metadata: babel-jest: ^29.0.0 || ^30.0.0 jest: ^29.0.0 || ^30.0.0 jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <7" + typescript: ">=4.3 <6" peerDependenciesMeta: "@babel/core": optional: true @@ -24953,7 +24994,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/8a76800cac1c102c789429a82655adde1d540d7ff025fc888006f4f458afe07ca4d3f73622a10a604edea06aa12c298605cd1a208a6380c0e3d591a3daed8af6 + checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e languageName: node linkType: hard @@ -25342,6 +25383,13 @@ __metadata: languageName: node linkType: hard +"universalify@npm:^0.2.0": + version: 0.2.0 + resolution: "universalify@npm:0.2.0" + checksum: 10/e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5 + languageName: node + linkType: hard + "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -25356,73 +25404,6 @@ __metadata: languageName: node linkType: hard -"unrs-resolver@npm:^1.7.11": - version: 1.11.1 - resolution: "unrs-resolver@npm:1.11.1" - dependencies: - "@unrs/resolver-binding-android-arm-eabi": "npm:1.11.1" - "@unrs/resolver-binding-android-arm64": "npm:1.11.1" - "@unrs/resolver-binding-darwin-arm64": "npm:1.11.1" - "@unrs/resolver-binding-darwin-x64": "npm:1.11.1" - "@unrs/resolver-binding-freebsd-x64": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-arm64-musl": "npm:1.11.1" - "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.11.1" - "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-x64-gnu": "npm:1.11.1" - "@unrs/resolver-binding-linux-x64-musl": "npm:1.11.1" - "@unrs/resolver-binding-wasm32-wasi": "npm:1.11.1" - "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.11.1" - "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.11.1" - "@unrs/resolver-binding-win32-x64-msvc": "npm:1.11.1" - napi-postinstall: "npm:^0.3.0" - dependenciesMeta: - "@unrs/resolver-binding-android-arm-eabi": - optional: true - "@unrs/resolver-binding-android-arm64": - optional: true - "@unrs/resolver-binding-darwin-arm64": - optional: true - "@unrs/resolver-binding-darwin-x64": - optional: true - "@unrs/resolver-binding-freebsd-x64": - optional: true - "@unrs/resolver-binding-linux-arm-gnueabihf": - optional: true - "@unrs/resolver-binding-linux-arm-musleabihf": - optional: true - "@unrs/resolver-binding-linux-arm64-gnu": - optional: true - "@unrs/resolver-binding-linux-arm64-musl": - optional: true - "@unrs/resolver-binding-linux-ppc64-gnu": - optional: true - "@unrs/resolver-binding-linux-riscv64-gnu": - optional: true - "@unrs/resolver-binding-linux-riscv64-musl": - optional: true - "@unrs/resolver-binding-linux-s390x-gnu": - optional: true - "@unrs/resolver-binding-linux-x64-gnu": - optional: true - "@unrs/resolver-binding-linux-x64-musl": - optional: true - "@unrs/resolver-binding-wasm32-wasi": - optional: true - "@unrs/resolver-binding-win32-arm64-msvc": - optional: true - "@unrs/resolver-binding-win32-ia32-msvc": - optional: true - "@unrs/resolver-binding-win32-x64-msvc": - optional: true - checksum: 10/4de653508cbaae47883a896bd5cdfef0e5e87b428d62620d16fd35cd534beaebf08ebf0cf2f8b4922aa947b2fe745180facf6cc3f39ba364f7ce0f974cb06a70 - languageName: node - linkType: hard - "unzipper@npm:^0.12.3": version: 0.12.3 resolution: "unzipper@npm:0.12.3" @@ -25498,6 +25479,25 @@ __metadata: languageName: node linkType: hard +"url-parse@npm:^1.5.3": + version: 1.5.10 + resolution: "url-parse@npm:1.5.10" + dependencies: + querystringify: "npm:^2.1.1" + requires-port: "npm:^1.0.0" + checksum: 10/c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad + languageName: node + linkType: hard + +"use-sync-external-store@npm:^1.6.0": + version: 1.6.0 + resolution: "use-sync-external-store@npm:1.6.0" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e + languageName: node + linkType: hard + "utf8@npm:^3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0" @@ -25677,12 +25677,12 @@ __metadata: languageName: node linkType: hard -"w3c-xmlserializer@npm:^5.0.0": - version: 5.0.0 - resolution: "w3c-xmlserializer@npm:5.0.0" +"w3c-xmlserializer@npm:^4.0.0": + version: 4.0.0 + resolution: "w3c-xmlserializer@npm:4.0.0" dependencies: - xml-name-validator: "npm:^5.0.0" - checksum: 10/d78f59e6b4f924aa53b6dfc56949959229cae7fe05ea9374eb38d11edcec01398b7f5d7a12576bd5acc57ff446abb5c9115cd83b9d882555015437cf858d42f0 + xml-name-validator: "npm:^4.0.0" + checksum: 10/9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee languageName: node linkType: hard @@ -25954,6 +25954,15 @@ __metadata: languageName: node linkType: hard +"whatwg-encoding@npm:^2.0.0": + version: 2.0.0 + resolution: "whatwg-encoding@npm:2.0.0" + dependencies: + iconv-lite: "npm:0.6.3" + checksum: 10/162d712d88fd134a4fe587e53302da812eb4215a1baa4c394dfd86eff31d0a079ff932c05233857997de07481093358d6e7587997358f49b8a580a777be22089 + languageName: node + linkType: hard + "whatwg-encoding@npm:^3.1.1": version: 3.1.1 resolution: "whatwg-encoding@npm:3.1.1" @@ -25970,6 +25979,13 @@ __metadata: languageName: node linkType: hard +"whatwg-mimetype@npm:^3.0.0": + version: 3.0.0 + resolution: "whatwg-mimetype@npm:3.0.0" + checksum: 10/96f9f628c663c2ae05412c185ca81b3df54bcb921ab52fe9ebc0081c1720f25d770665401eb2338ab7f48c71568133845638e18a81ed52ab5d4dcef7d22b40ef + languageName: node + linkType: hard + "whatwg-mimetype@npm:^4.0.0": version: 4.0.0 resolution: "whatwg-mimetype@npm:4.0.0" @@ -25977,13 +25993,13 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1": - version: 14.2.0 - resolution: "whatwg-url@npm:14.2.0" +"whatwg-url@npm:^11.0.0": + version: 11.0.0 + resolution: "whatwg-url@npm:11.0.0" dependencies: - tr46: "npm:^5.1.0" + tr46: "npm:^3.0.0" webidl-conversions: "npm:^7.0.0" - checksum: 10/f0a95b0601c64f417c471536a2d828b4c16fe37c13662483a32f02f183ed0f441616609b0663fb791e524e8cd56d9a86dd7366b1fc5356048ccb09b576495e7c + checksum: 10/dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 languageName: node linkType: hard @@ -25997,6 +26013,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -26091,6 +26114,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^8.0.1, wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -26128,7 +26162,17 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^5.0.0, write-file-atomic@npm:^5.0.1": +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 10/3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 + languageName: node + linkType: hard + +"write-file-atomic@npm:^5.0.0": version: 5.0.1 resolution: "write-file-atomic@npm:5.0.1" dependencies: @@ -26198,7 +26242,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.18.0, ws@npm:^8.18.3": +"ws@npm:^8.11.0, ws@npm:^8.18.0, ws@npm:^8.18.3": version: 8.21.0 resolution: "ws@npm:8.21.0" peerDependencies: @@ -26247,10 +26291,10 @@ __metadata: languageName: node linkType: hard -"xml-name-validator@npm:^5.0.0": - version: 5.0.0 - resolution: "xml-name-validator@npm:5.0.0" - checksum: 10/43f30f3f6786e406dd665acf08cd742d5f8a46486bd72517edb04b27d1bcd1599664c2a4a99fc3f1e56a3194bff588b12f178b7972bc45c8047bdc4c3ac8d4a1 +"xml-name-validator@npm:^4.0.0": + version: 4.0.0 + resolution: "xml-name-validator@npm:4.0.0" + checksum: 10/f9582a3f281f790344a471c207516e29e293c6041b2c20d84dd6e58832cd7c19796c47e108fd4fd4b164a5e72ad94f2268f8ace8231cde4a2c6428d6aa220f92 languageName: node linkType: hard @@ -26268,6 +26312,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -26305,6 +26356,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -26312,7 +26373,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -26327,6 +26388,24 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 18c67ef7a322c1c97774f0dc12ffa7c79d93e422 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 16:25:08 +0200 Subject: [PATCH 02/67] feat: update readme with architecture doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/README.md | 627 +++++++++++++++++++++++++++++- 1 file changed, 626 insertions(+), 1 deletion(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 5cc5ccfcb25..5c9d3a83308 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -1,4 +1,4 @@ -# `@metamask/kyc-controller` +# KYC Controller `@metamask/kyc-controller` Shared KYC / identity verification controller used across MetaMask clients @@ -21,3 +21,628 @@ This watches `src/**/*.ts` and re-runs the build on each change (it also perform ## Contributing This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). + + +## Architecture + +`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns +the end-to-end KYC / identity-verification flow used across MetaMask clients +(mobile, extension, web). It hides the vendor implementation (currently +**MoonPay** for identity + **SumSub** for document verification) behind a +vendor-neutral, per-product surface consumed by features such as **ramps** and +**card**. + +This document explains: + +- The package's internal building blocks and responsibilities. +- How the pieces communicate (messenger actions, injected adapters). +- The identity flow as a state machine and an end-to-end sequence. +- The encrypted frame message protocol and crypto. +- How the **metamask-mobile** client wires everything together on the client + side. + +--- + +### 1. Design principles + +The package is built around a few deliberate constraints: + +| Principle | How it shows up in the code | +| --- | --- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | + +--- + +### 2. Component overview + +The package splits cleanly into a **stateful orchestrator** (`KycController`), a +**stateless HTTP client** (`KycService`), and supporting modules (crypto, +selectors, types). + +```mermaid +graph TB + subgraph pkg["@metamask/kyc-controller"] + direction TB + Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Service["KycService
(stateless)
HTTP + response validation"] + Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] + Selectors["selectors.ts
memoized reselect selectors"] + Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Country["countryCodes.ts
alpha-2 → alpha-3"] + end + + subgraph deps["External MetaMask dependencies"] + Base["@metamask/base-controller"] + Msgr["@metamask/messenger"] + CU["@metamask/controller-utils
createServicePolicy, HttpError"] + Geo["GeolocationController"] + Auth["AuthenticationController
(profile-sync)"] + end + + subgraph vendor["Vendor backends (HTTP / frames)"] + UKYC["Universal KYC API
kyc-api.cx.metamask.io"] + Frames["MoonPay frames
blocks.moonpay.com"] + SumSubSDK["SumSub SDK
(native / web)"] + end + + Controller -->|"decryptCredentials()"| Crypto + Controller -->|"messenger.call(KycService:*)"| Service + Controller -.->|"injected launcher"| SumSubSDK + Controller -->|"builds frame URLs
handles frame messages"| Frames + + Service -->|"createServicePolicy / HttpError"| CU + Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo + Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth + Service -->|"fetch()"| UKYC + + Controller --- Base + Controller --- Msgr + Service --- Msgr + Selectors -.->|"read"| Controller +``` + +#### 2.1 `KycController` + +- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. +- Holds **all flow state** (see [§3](#3-state-shape)). +- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — + never persisted, used only for the frame key exchange. +- Registers its public methods as messenger actions via + `registerMethodActionHandlers`. +- Calls `KycService` exclusively **through the messenger** (`KycService:*` + actions), never a direct reference. +- Delegates SumSub SDK presentation to an injected `sumsubLauncher` + (`KycSumSubLauncher`). +- When the flow is scoped to a product (passed to `initialize` / + `acceptTermsAndStartSession` and stored as `activeProduct`), automatically + runs the KYC-required check once authenticated and chains into document + verification when KYC is required — no extra consumer calls needed. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, +`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, +`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, +`startSumSub`, `reset`. + +#### 2.2 `KycService` + +- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) + backend. +- Base URL derived from `env` (`production` / `development`) or an explicit + `baseUrl` override. +- Every request is wrapped in a **service policy** (`createServicePolicy`) for + retries/circuit-breaking, and carries a **bearer token** obtained from + `AuthenticationController:getBearerToken`. +- Every response is validated with **superstruct** before being returned; + malformed responses throw a descriptive error. +- Resolves the customer's country from `GeolocationController:getGeolocation` + and maps alpha-2 → alpha-3. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, +`createUkycSession`, `submitWrappedKey`. + +Endpoints: + +| Method | HTTP | Endpoint | Purpose | +| --- | --- | --- | --- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow | +| `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | + +### 2.3 `crypto.ts` + +Implements the Check/Auth frame credential decryption: + +1. Client generates an X25519 keypair; the public key (hex) is added to the + frame URL. +2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. +3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then + `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the + ciphertext (which includes the 16-byte tag). IV must be 12 bytes. + +It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), +and hex-or-base64 binary fields. + +#### 2.4 `selectors.ts` + +Memoized `reselect` selectors over `KycControllerState`: +`selectKycPhase`, `selectKycSumSub`, and the parametric +`selectIsKycRequiredForProduct(product)`. + +--- + +### 3. State shape + +```mermaid +classDiagram + class KycControllerState { + +KycPhase phase + +string statusMessage + +string error + +string email + +string termsAcceptedAt [persisted] + +string[] acceptedDisclaimerIds [persisted] + +KycDisclaimer[] disclaimers + +string disclaimersError + +string geoCountry + +string sessionToken [secret] + +string accessToken [secret] + +string moonpayCustomerId + +KycProduct activeProduct + +Record kycRequiredByProduct [persisted] + +string lastCheckedAt [persisted] + +SumSubState sumsub + } + class SumSubState { + +KycSumSubStatus status + +Json result + +string sessionId + +string applicantAccessToken + } + KycControllerState --> SumSubState : sumsub +``` + +> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> `T | null` in the source; `Record` is `Partial>`. +> Types are simplified above for diagram readability. + +State metadata highlights (`kycControllerMetadata`): + +- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, + `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow + can skip already-accepted terms and reuse cached results. +- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, + `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. +- Additional non-state secrets kept **off** the state object entirely: the + X25519 private key (`#keypair`) and the Auth-frame client token + (`#authClientToken`). + +--- + +### 4. The identity flow (phase state machine) + +`KycPhase` models the linear identity flow. Each transition is driven by a +controller method or an incoming frame message. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> terms : initialize() (no saved terms) + idle --> session : initialize() (saved terms + email) + + terms --> session : acceptTermsAndStartSession() + session --> check : createSession() ok + session --> terms : createSession() fails (clears saved terms) + + check --> form : Check frame → active (already authenticated) + check --> auth : Check frame → connectionRequired (needs OTP) + check --> terms : Check frame → termsAcceptanceRequired + + auth --> form : Auth frame → active (OTP verified) + auth --> terms : Auth frame → termsAcceptanceRequired + + form --> submit : checkKycRequired()
(auto when a product is set) + submit --> done : kyc-required response ok + submit --> error : request failed + + check --> error : unexpected status / decrypt failure + auth --> error : unexpected status + done --> [*] + error --> idle : reset() + done --> idle : reset() +``` + +> When the flow is scoped to a product (a `product` is passed to `initialize` +> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the +> KYC-required check (`form → submit → done`) with no user interaction, and — if +> KYC is required — automatically launches the SumSub document-verification +> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops +> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. + +Phase meanings (from `types.ts`): + +| Phase | Meaning | +| --- | --- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | + +--- + +### 5. End-to-end sequence + +This sequence shows the full happy path including the two frames and the SumSub +hand-off. The **client transport** (WebView on mobile, iframe on web) is +generic — it only forwards raw frame messages to `handleFrameMessage` and posts +back any returned `reply`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Client UI + transport
(WebView/iframe) + participant Ctrl as KycController + participant Svc as KycService + participant Geo as GeolocationController + participant API as UKYC API + participant Frame as MoonPay Check/Auth frame + participant Launcher as SumSub launcher (injected) + + User->>Ctrl: initialize({ email, product }) + Ctrl->>Svc: getGeoCountry() + Svc->>Geo: getGeolocation() + Note over Svc: map alpha-2 → alpha-3 locally + Ctrl->>Svc: fetchDisclaimers({ country }) + Svc->>API: GET /disclaimers + Ctrl-->>UI: phase = terms (+ disclaimers) + + User->>Ctrl: acceptTermsAndStartSession({ email }) + Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) + Svc->>API: POST /sessions + Ctrl-->>UI: phase = check (+ sessionToken) + + UI->>Ctrl: buildCheckFrameUrl() + Ctrl-->>UI: URL (sessionToken + publicKey) + UI->>Frame: load Check frame (invisible) + Frame-->>UI: handshake + UI->>Ctrl: handleFrameMessage(handshake) + Ctrl-->>UI: reply = ack + UI->>Frame: post ack + Frame-->>UI: complete (status + encrypted credentials) + UI->>Ctrl: handleFrameMessage(complete) + Note over Ctrl: decryptCredentials() → accessToken / clientToken + + alt Check → connectionRequired + Ctrl-->>UI: phase = auth + UI->>Frame: load Auth frame (visible, OTP) + Frame-->>UI: complete (active + credentials) + UI->>Ctrl: handleFrameMessage(complete) + end + + Ctrl-->>UI: phase = form (accessToken set) + + Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) + Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) + Svc->>API: POST /kyc-required + Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) + + opt kycRequired === true → auto-launch document verification + Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata }) + Svc->>API: POST /sessions + Ctrl->>Svc: submitWrappedKey({ sessionId, wrappedUserKey, ... }) + Svc->>API: POST /sessions/{id}/wrapped-key + Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) + Launcher-->>Ctrl: SDK result + Ctrl-->>UI: sumsub.status = complete (+ result) + end +``` + +> The KYC-required check and the document-verification launch after `form` are +> driven by the controller itself, not the user — the flow captures the +> `product` at `initialize` and continues automatically. If `initialize` is +> called without a `product`, the flow stops at `form` and the consumer triggers +> `checkKycRequired` (and later `startSumSub`) explicitly. + +--- + +### 6. Frame message protocol & crypto + +The Check, Auth and Reset frames all speak a small `postMessage` protocol. +`KycController.handleFrameMessage` implements the identity portion; the client +transport is responsible only for delivering messages and injecting replies. + +```mermaid +sequenceDiagram + autonumber + participant Frame as MoonPay frame + participant UI as Client transport + participant Ctrl as KycController + + Frame->>UI: { kind: "handshake", meta:{channelId} } + UI->>Ctrl: handleFrameMessage({ message }) + Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } + UI->>Frame: postMessage(ack) + + Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } + UI->>Ctrl: handleFrameMessage({ message }) + Note over Ctrl: 1. store customer.id (moonpayCustomerId)
2. decryptCredentials(envelope, privKey)
3. route by channelId (ch_1 Check / ch_2 Auth) + Ctrl->>Ctrl: apply outcome → next phase +``` + +Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. + +Credential decryption (`crypto.ts`): + +```mermaid +graph LR + A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] + B --> C["HKDF-SHA256
key (32 bytes)"] + C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] + D --> E["JSON credentials
{ accessToken?, clientToken? }"] +``` + +Check-frame outcomes (`#handleCheckOutcome`): + +- `active` + `accessToken` → phase `form` (already authenticated). +- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +Auth-frame outcomes (`#handleAuthOutcome`): + +- `active` + `accessToken` → phase `form`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +--- + +### 7. SumSub sub-flow + +The document-verification sub-flow tracks its own status independently of the +identity `phase`, and delegates the actual SDK presentation to the injected +launcher. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> creatingSession : startSumSub() + creatingSession --> fetchingToken : createUkycSession() ok + fetchingToken --> launching : submitWrappedKey() ok + launching --> inProgress : launcher.onStatusChange + inProgress --> complete : status = Completed + launching --> complete : SDK resolves + creatingSession --> failed : error + fetchingToken --> failed : error + launching --> failed : launcher unavailable / error +``` + +The `KycSumSubLauncher` interface (injected per client): + +```ts +type KycSumSubLauncher = { + isAvailable(): boolean; + launch(params: KycSumSubLaunchParams): Promise>; +}; +``` + +`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the +controller re-runs `submitWrappedKey` to refresh), and an `onStatusChange` +callback that the controller maps into `sumsub.status`. + +--- + +### 8. Messenger wiring + +Both classes are messenger-driven. The controller depends on the service's +actions; the service depends on auth + geolocation actions from other +controllers. + +```mermaid +graph LR + subgraph CtrlMsgr["KycControllerMessenger"] + C_own["Own actions:
KycController:getState + 12 methods"] + C_ext["Allowed (delegated):
KycService:*"] + end + subgraph SvcMsgr["KycServiceMessenger"] + S_own["Own actions:
KycService: 6 methods"] + S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] + end + + C_ext -.delegates.-> S_own + S_ext -.delegates.-> Auth["AuthenticationController"] + S_ext -.delegates.-> Geo["GeolocationController"] +``` + +- `KycController` emits `KycController:stateChange` and exposes + `KycController:getState` plus its method actions. +- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call + the service. +- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation + actions. + +--- + +### 9. Client-side usage (metamask-mobile) + +The mobile app is a reference consumer. It wires the controller/service into the +Engine, injects a React Native SumSub launcher, bridges WebView frame messages, +and reads state through Redux selectors. The **package stays free of any of +this** — all React/native/WebView code lives in the app. + +```mermaid +graph TB + subgraph app["metamask-mobile"] + direction TB + subgraph engine["Engine wiring"] + CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] + SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] + CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] + SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] + Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] + end + subgraph ui["UI layer"] + Hook["useKycFlow.ts
binds controller ↔ React"] + Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] + Reset["useMoonpayReset.ts
Reset frame"] + Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] + end + subgraph redux["Redux"] + Sel["selectors/kycController.ts
wraps core selectors"] + end + end + + subgraph core["@metamask/kyc-controller"] + KC["KycController"] + KS["KycService"] + end + + CInit --> KC + SInit --> KS + CInit --> Launcher + Launcher -. injected .-> KC + CMsgr --> KC + SMsgr --> KS + + Hook -->|"Engine.context.KycController.*"| KC + Hook -->|"useSelector"| Sel + Sel -->|"state.engine.backgroundState.KycController"| KC + Frame -->|"raw frame message"| Hook + Hook -->|"handleFrameMessage()"| KC + Demo --> Hook + Demo --> Frame + Demo --> Reset +``` + +#### 9.1 Engine wiring + +- **`kyc-controller-init.ts`** constructs `KycController` with the persisted + state slice and injects `reactNativeSumSubLauncher`. +- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an + `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override. +- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to + the controller's messenger. +- **`kyc-service-messenger.ts`** delegates + `AuthenticationController:getBearerToken` and + `GeolocationController:getGeolocation` to the service's messenger. + +#### 9.2 SumSub launcher adapter + +`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: + +- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). +- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so + merely wiring the controller never loads the native module — important for + Jest / Expo Go), initializes the SDK with the applicant token, and forwards + `onStatusChanged` / token-expiration callbacks back to the controller. + +#### 9.3 React binding — `useKycFlow` + +A thin hook that: + +- Reads controller state from Redux via the `selectors/kycController.ts` + selectors. +- Forwards user intents to controller actions through + `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, + `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). +- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as + the phase changes. +- Bridges WebView frame messages into `handleFrameMessage` and posts back the + returned `reply`. +- Keeps view-only concerns (email input, debug log, frame visibility) in local + React state. + +#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` + +- Injects a `postMessage` bridge into the frame that forwards the frame's + outbound messages to React Native via `window.ReactNativeWebView.postMessage`. +- **Validates the origin** (`https://blocks.moonpay.com`) before handing a + message to the controller. +- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on + both `document` and `window` (platform quirk between iOS WKWebView and Android + System WebView). +- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user + toggles it in the debug panel; the Auth frame is rendered visibly for OTP. + +#### 9.5 Redux selectors + +`selectors/kycController.ts` wraps the package's core selectors and reads the +slice at `state.engine.backgroundState.KycController`, exposing app-friendly +selectors (`selectKycPhase`, `selectKycSumSub`, +`selectIsKycRequiredForProduct(product)`, plus per-field selectors). + +--- + +### 10. Boundaries & responsibilities summary + +```mermaid +graph LR + subgraph shared["Shared package (platform-agnostic)"] + A1["Flow orchestration + state"] + A2["HTTP + response validation"] + A3["Crypto (X25519 / AES-GCM)"] + A4["Frame message protocol"] + A5["Selectors + vendor-neutral types"] + end + subgraph client["Client (per platform)"] + B1["Engine/DI wiring"] + B2["WebView / iframe transport"] + B3["SumSub SDK launcher"] + B4["Auth token + geolocation providers"] + B5["UI + Redux binding"] + end + shared -. injected adapters .- client +``` + +| Concern | Owner | +| --- | --- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | + +--- + +### Appendix — key source files + +| File | Responsibility | +| --- | --- | +| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | + +Reference client (metamask-mobile): + +| File | Responsibility | +| --- | --- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | + From 400b874c21bb849c3bd3662d716bfe624a965ea5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 16:25:59 +0200 Subject: [PATCH 03/67] feat: add product support + automatic calls to kyc required and sumsub flow opening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/CHANGELOG.md | 7 +- .../src/KycController-method-action-types.ts | 8 + .../kyc-controller/src/KycController.test.ts | 165 +++++++++++++++++- packages/kyc-controller/src/KycController.ts | 93 ++++++++-- packages/kyc-controller/src/types.ts | 7 +- 5 files changed, 265 insertions(+), 15 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 90c4dac4c9c..22e08cf4f2a 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,10 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. +- Add automatic post-authentication continuation to `KycController` + - `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state. + - When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior). +- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index a8610c695ad..92e34d45eb9 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -11,6 +11,11 @@ import type { KycController } from './KycController'; * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. */ export type KycControllerInitializeAction = { type: `KycController:initialize`; @@ -34,6 +39,9 @@ export type KycControllerLoadDisclaimersAction = { * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. */ export type KycControllerAcceptTermsAndStartSessionAction = { type: `KycController:acceptTermsAndStartSession`; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 5ab54f75aed..d3f970b1287 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -104,6 +104,17 @@ describe('KycController', () => { }); }); + it('captures the active product for the automatic post-auth continuation', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ product: 'card' }); + + expect(controller.state.activeProduct).toBe('card'); + }); + }); + it('stays on terms when terms exist but no email is available', async () => { await withController( { @@ -188,10 +199,14 @@ describe('KycController', () => { async ({ controller, handlers }) => { handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); - await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'ramps', + }); expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.activeProduct).toBe('ramps'); expect(controller.state.phase).toBe('check'); }, ); @@ -467,6 +482,152 @@ describe('KycController', () => { }); }); + describe('automatic post-authentication continuation', () => { + it('stays at form and does not run the check when no product is set', async () => { + await withController( + { options: { state: { sessionToken: 'tok', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('form'); + expect(handlers.checkKycRequired).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-runs the KYC check on reaching form and stops at done when KYC is not required', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.kycRequiredByProduct.ramps).toBe(false); + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-chains into document verification when KYC is required (via the auth frame)', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + const envelope = envelopeFor(controller, { accessToken: 'access-2' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.kycRequiredByProduct.card).toBe(true); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('records a failed sub-flow without throwing when verification is required but the SDK is unavailable', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.isAvailable.mockReturnValue(false); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('failed'); + }, + ); + }); + + it('does not launch verification when the auto-run check fails', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('error'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + }); + describe('frame URL builders', () => { it('returns null for the check frame without a session', async () => { await withController(({ controller }) => { @@ -662,6 +823,7 @@ describe('KycController', () => { phase: 'form', sessionToken: 'tok', accessToken: 'a', + activeProduct: 'ramps', termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'], kycRequiredByProduct: { ramps: true }, @@ -673,6 +835,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('idle'); expect(controller.state.sessionToken).toBeNull(); expect(controller.state.accessToken).toBeNull(); + expect(controller.state.activeProduct).toBeNull(); expect(controller.state.termsAcceptedAt).toBe('t'); expect(controller.state.kycRequiredByProduct.ramps).toBe(true); }, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 55b59d6a2ac..36a641d4623 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -68,6 +68,15 @@ export type KycControllerState = { /** Vendor customer id, used for the SumSub hand-off. */ moonpayCustomerId: string | null; + /** + * The product the current flow is running for. Captured at `initialize` + * (or `acceptTermsAndStartSession`) and used to automatically run the + * KYC-required check once authentication completes. `null` outside a + * product-scoped flow (in which case the flow stops at `form` and the + * consumer drives the check manually). + */ + activeProduct: KycProduct | null; + /** Cached "is KYC required" result per product (persisted). */ kycRequiredByProduct: Partial>; /** ISO-8601 timestamp of the last KYC-required check (persisted). */ @@ -155,6 +164,12 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, + activeProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, kycRequiredByProduct: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -194,6 +209,7 @@ export function getDefaultKycControllerState(): KycControllerState { sessionToken: null, accessToken: null, moonpayCustomerId: null, + activeProduct: null, kycRequiredByProduct: {}, lastCheckedAt: null, sumsub: { @@ -333,11 +349,24 @@ export class KycController extends BaseController< * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. */ - async initialize(params?: { email?: string }): Promise { - if (params?.email) { + async initialize(params?: { + email?: string; + product?: KycProduct; + }): Promise { + if (params?.email || params?.product) { this.update((state) => { - state.email = params.email as string; + if (params.email) { + state.email = params.email; + } + if (params.product) { + state.activeProduct = params.product; + } }); } @@ -404,8 +433,14 @@ export class KycController extends BaseController< * * @param params - Optional parameters. * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. */ - async acceptTermsAndStartSession(params?: { email?: string }): Promise { + async acceptTermsAndStartSession(params?: { + email?: string; + product?: KycProduct; + }): Promise { const termsAcceptedAt = new Date().toISOString(); const disclaimerIds = this.state.disclaimers.map( (disclaimer) => disclaimer.id, @@ -414,6 +449,9 @@ export class KycController extends BaseController< if (params?.email) { state.email = params.email; } + if (params?.product) { + state.activeProduct = params.product; + } state.termsAcceptedAt = termsAcceptedAt; state.acceptedDisclaimerIds = disclaimerIds; }); @@ -534,12 +572,12 @@ export class KycController extends BaseController< } if (channelId === CHANNEL_CHECK) { - this.#handleCheckOutcome(status, accessToken, clientToken); + await this.#handleCheckOutcome(status, accessToken, clientToken); return {}; } if (channelId === CHANNEL_AUTH) { - this.#handleAuthOutcome(status, accessToken); + await this.#handleAuthOutcome(status, accessToken); return {}; } @@ -553,17 +591,18 @@ export class KycController extends BaseController< * @param accessToken - The decrypted access token, if any. * @param clientToken - The decrypted client token, if any. */ - #handleCheckOutcome( + async #handleCheckOutcome( status: NonNullable['status'], accessToken?: string, clientToken?: string, - ): void { + ): Promise { if (status === 'active' && accessToken) { this.update((state) => { state.accessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Already authenticated. Review to submit.'; }); + await this.#continueAfterAuthentication(); return; } if (status === 'connectionRequired' && clientToken) { @@ -587,16 +626,17 @@ export class KycController extends BaseController< * @param status - The frame status. * @param accessToken - The decrypted access token, if any. */ - #handleAuthOutcome( + async #handleAuthOutcome( status: NonNullable['status'], accessToken?: string, - ): void { + ): Promise { if (status === 'active' && accessToken) { this.update((state) => { state.accessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Authenticated. Review to submit.'; }); + await this.#continueAfterAuthentication(); return; } if (status === 'termsAcceptanceRequired') { @@ -606,6 +646,38 @@ export class KycController extends BaseController< this.#fail(`Auth frame returned status: ${status}`); } + /** + * Continues the flow once authentication has completed (phase `form`). + * + * When the flow is scoped to a product (see {@link initialize}), the + * KYC-required check runs automatically, and — when KYC is required — the + * document-verification sub-flow is launched. When no product is set, this is + * a no-op and the flow stays at `form` for the consumer to drive manually. + * + * Errors are already recorded on state by `checkKycRequired` (`error` + * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows + * them so it can be awaited safely from the frame-message handler. + */ + async #continueAfterAuthentication(): Promise { + const product = this.state.activeProduct; + if (!product) { + return; + } + + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { + return; + } + + try { + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. + } + } + /** * Invalidates stored terms and returns to the terms phase. */ @@ -832,6 +904,7 @@ export class KycController extends BaseController< state.sessionToken = null; state.accessToken = null; state.moonpayCustomerId = null; + state.activeProduct = null; state.sumsub = { status: 'idle', result: null, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 4d41052f8fa..7861a293921 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -25,9 +25,12 @@ export type KycVendor = 'moonpay'; * - `session` — creating the vendor session. * - `check` — running the invisible connection-check frame. * - `auth` — running the visible authentication (OTP) frame. - * - `form` — authenticated; ready to submit the identity check. + * - `form` — authenticated. When the flow is scoped to a product, the + * KYC-required check runs automatically from here; otherwise the consumer + * drives it manually via `checkKycRequired`. * - `submit` — submitting the KYC-required check. - * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is + * required, the document-verification sub-flow is launched automatically. * - `error` — flow halted; see `error`. */ export type KycPhase = From 9b3de4ca24ada1ea4c5a20b85c69f90b5dab56db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 17:05:55 +0200 Subject: [PATCH 04/67] fix: geoCountry not updated issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 31 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 6 ++-- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index d3f970b1287..00661198987 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -147,6 +147,37 @@ describe('KycController', () => { }); }); + it('caches the provided country override in geoCountry', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.geoCountry).toBe('USA'); + }); + }); + + it('lets a later checkKycRequired reuse the overridden country without an override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + await controller.loadDisclaimers({ country: 'USA' }); + await controller.checkKycRequired({ product: 'ramps' }); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.error).toBeNull(); + }, + ); + }); + it('uses the cached geoCountry when no country is provided', async () => { await withController( { options: { state: { geoCountry: 'USA' } } }, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 36a641d4623..4410f2734ea 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -403,11 +403,11 @@ export class KycController extends BaseController< */ async loadDisclaimers(params?: { country?: string }): Promise { try { - const cachedCountry = params?.country ?? this.state.geoCountry; const country = - cachedCountry ?? + params?.country ?? + this.state.geoCountry ?? (await this.messenger.call('KycService:getGeoCountry')); - if (!cachedCountry) { + if (country !== this.state.geoCountry) { this.update((state) => { state.geoCountry = country; }); From 133fa9b6bc63d9d30a6c951dffac3455583add1e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 17:09:23 +0200 Subject: [PATCH 05/67] fix: previously selected product is not reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 14 +++++++++++++ packages/kyc-controller/src/KycController.ts | 20 +++++++++---------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 00661198987..0622d121e52 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -115,6 +115,20 @@ describe('KycController', () => { }); }); + it('clears a stale active product when re-initialized without one', async () => { + await withController( + { options: { state: { activeProduct: 'card' } } }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + it('stays on terms when terms exist but no email is available', async () => { await withController( { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 4410f2734ea..f1234254960 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -359,16 +359,16 @@ export class KycController extends BaseController< email?: string; product?: KycProduct; }): Promise { - if (params?.email || params?.product) { - this.update((state) => { - if (params.email) { - state.email = params.email; - } - if (params.product) { - state.activeProduct = params.product; - } - }); - } + // `initialize` starts a fresh flow, so `activeProduct` is always reset to + // this call's product (or `null`). Otherwise a prior run's product could + // linger and cause `#continueAfterAuthentication` to auto-run the check / + // sub-flow when the caller intended the manual (product-less) flow. + this.update((state) => { + if (params?.email) { + state.email = params.email; + } + state.activeProduct = params?.product ?? null; + }); // Resolve country for display; non-blocking. try { From 89ac0b42891e727c58eaa0b53d1dc1622e73bc89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 18:33:51 +0200 Subject: [PATCH 06/67] fix: issue with sumsub complete status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 26 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 20 ++++++++++++-- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 0622d121e52..e62d5a3dbe0 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -596,6 +596,10 @@ describe('KycController', () => { }, async ({ controller, handlers, launcher }) => { handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); const envelope = envelopeFor(controller, { accessToken: 'access-2' }); await controller.handleFrameMessage({ @@ -836,6 +840,11 @@ describe('KycController', () => { it('defaults locale and debug when no params are given', async () => { await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + await controller.startSumSub(); expect(launcher.launch).toHaveBeenCalledWith( @@ -845,6 +854,23 @@ describe('KycController', () => { }); }); + it('marks failed when launch resolves without a Completed status', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // The applicant abandons the flow: the SDK reports progress but never + // a Completed status, yet `launch` still resolves. + onStatusChange?.('idle', 'InProgress'); + return { ok: false }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: false }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.result).toStrictEqual({ ok: false }); + }); + }); + it('marks failed and returns the error when a step throws', async () => { await withController(async ({ controller, handlers }) => { handlers.createUkycSession.mockRejectedValue(new Error('ukyc down')); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index f1234254960..08696b5a089 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -32,6 +32,11 @@ const CHANNEL_RESET = 'ch_reset'; // must be replaced with real UKYC-issued material before production use. const MOCK_JWT_TOKEN = 'mock-jwt-token'; +// The SumSub SDK status that signals the applicant finished the flow +// successfully. Any other resolution (abandonment, failure, or a non-success +// outcome) must not be recorded as `complete`. +const SUMSUB_COMPLETED_STATUS = 'Completed'; + // === STATE === /** @@ -855,6 +860,11 @@ export class KycController extends BaseController< state.sumsub.applicantAccessToken = applicantAccessToken; }); + // Track whether the SDK ever reported a successful completion. A resolved + // `launch` alone does not imply success — the applicant may have + // abandoned the flow or the SDK may have reported a non-success outcome. + let reachedCompletion = false; + const result = await this.#sumsubLauncher.launch({ applicantAccessToken, onTokenExpiration: async () => { @@ -865,17 +875,23 @@ export class KycController extends BaseController< return refreshed.applicantAccessToken; }, onStatusChange: (_prev, next) => { + if (next === SUMSUB_COMPLETED_STATUS) { + reachedCompletion = true; + } this.update((state) => { state.sumsub.status = - next === 'Completed' ? 'complete' : 'inProgress'; + next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress'; }); }, locale: params?.locale ?? 'en', debug: params?.debug ?? false, }); + // Only record `complete` when the SDK actually reported completion; + // otherwise treat the resolved-but-unfinished flow as `failed` so + // consumers and UI do not mistake it for a finished verification. this.update((state) => { - state.sumsub.status = 'complete'; + state.sumsub.status = reachedCompletion ? 'complete' : 'failed'; state.sumsub.result = result as Json; }); return result; From 3392d001eff6943e7733897c34b3de3836789157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 18:36:43 +0200 Subject: [PATCH 07/67] feat: update lock file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- yarn.lock | 2488 +++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 1737 insertions(+), 751 deletions(-) diff --git a/yarn.lock b/yarn.lock index 2a784809575..a78434321db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -242,7 +242,20 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.29.7": +"@asamuzakjp/css-color@npm:^3.2.0": + version: 3.2.0 + resolution: "@asamuzakjp/css-color@npm:3.2.0" + dependencies: + "@csstools/css-calc": "npm:^2.1.3" + "@csstools/css-color-parser": "npm:^3.0.9" + "@csstools/css-parser-algorithms": "npm:^3.0.4" + "@csstools/css-tokenizer": "npm:^3.0.3" + lru-cache: "npm:^10.4.3" + checksum: 10/870f661460173174fef8bfebea0799ba26566f3aa7b307e5adabb7aae84fed2da68e40080104ed0c83b43c5be632ee409e65396af13bfe948a3ef4c2c729ecd9 + languageName: node + linkType: hard + +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -260,7 +273,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -283,7 +296,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" dependencies: @@ -687,7 +700,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -786,7 +799,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1659,7 +1672,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -1727,7 +1740,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-calc@npm:^2.1.4": +"@csstools/css-calc@npm:^2.1.3, @csstools/css-calc@npm:^2.1.4": version: 2.1.4 resolution: "@csstools/css-calc@npm:2.1.4" peerDependencies: @@ -1737,7 +1750,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-color-parser@npm:^3.1.0": +"@csstools/css-color-parser@npm:^3.0.9, @csstools/css-color-parser@npm:^3.1.0": version: 3.1.0 resolution: "@csstools/css-color-parser@npm:3.1.0" dependencies: @@ -1750,7 +1763,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-parser-algorithms@npm:^3.0.5": +"@csstools/css-parser-algorithms@npm:^3.0.4, @csstools/css-parser-algorithms@npm:^3.0.5": version: 3.0.5 resolution: "@csstools/css-parser-algorithms@npm:3.0.5" peerDependencies: @@ -1759,7 +1772,7 @@ __metadata: languageName: node linkType: hard -"@csstools/css-tokenizer@npm:^3.0.4": +"@csstools/css-tokenizer@npm:^3.0.3, @csstools/css-tokenizer@npm:^3.0.4": version: 3.0.4 resolution: "@csstools/css-tokenizer@npm:3.0.4" checksum: 10/eb6c84c086312f6bb8758dfe2c85addd7475b0927333c5e39a4d59fb210b9810f8c346972046f95e60a721329cffe98895abe451e51de753ad1ca7a8c24ec65f @@ -4701,6 +4714,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/console@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + jest-message-util: "npm:30.4.1" + jest-util: "npm:30.4.1" + slash: "npm:^3.0.0" + checksum: 10/4eb463d29654c20716f5f9cde43e5d958cb3b9234477df57da5b3814c3f1a4a0ab611a8eaf4b5abc146190a012584d7025f445f3560ed62acd843fc95c0a0e65 + languageName: node + linkType: hard + "@jest/console@npm:^29.7.0": version: 29.7.0 resolution: "@jest/console@npm:29.7.0" @@ -4715,6 +4742,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:30.4.2": + version: 30.4.2 + resolution: "@jest/core@npm:30.4.2" + dependencies: + "@jest/console": "npm:30.4.1" + "@jest/pattern": "npm:30.4.0" + "@jest/reporters": "npm:30.4.1" + "@jest/test-result": "npm:30.4.1" + "@jest/transform": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + exit-x: "npm:^0.2.2" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-changed-files: "npm:30.4.1" + jest-config: "npm:30.4.2" + jest-haste-map: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-regex-util: "npm:30.4.0" + jest-resolve: "npm:30.4.1" + jest-resolve-dependencies: "npm:30.4.2" + jest-runner: "npm:30.4.2" + jest-runtime: "npm:30.4.2" + jest-snapshot: "npm:30.4.1" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + jest-watcher: "npm:30.4.1" + pretty-format: "npm:30.4.1" + slash: "npm:^3.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/ecc695392685ab56c6df5d29d6f7927141071d8f3f75e5f7c7664f0faded1307caf5daf051074252d5ddb9546bf2bfe3b0c63ca81fe6238dc34e1bb5f8a7a261 + languageName: node + linkType: hard + "@jest/core@npm:^29.7.0": version: 29.7.0 resolution: "@jest/core@npm:29.7.0" @@ -4756,6 +4824,46 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/diff-sequences@npm:30.4.0" + checksum: 10/65c27937c10a7157899dad5d176806104286f9d55464f318955a0cee98db8aed6b8f70ad4aee7133468087146422cdd391d49b1e101ec543db3283ee4eb59c06 + languageName: node + linkType: hard + +"@jest/environment-jsdom-abstract@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/environment-jsdom-abstract@npm:30.4.1" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/fake-timers": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/jsdom": "npm:^21.1.7" + "@types/node": "npm:*" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + peerDependencies: + canvas: ^3.0.0 + jsdom: "*" + peerDependenciesMeta: + canvas: + optional: true + checksum: 10/e51537587162d0972df0abd6f0ce0b2cf5245029035e6b61b62da075547a37cd174254c315b456447a6419b22938623036b355c063498b7876f7ea0352404fa6 + languageName: node + linkType: hard + +"@jest/environment@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/environment@npm:30.4.1" + dependencies: + "@jest/fake-timers": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-mock: "npm:30.4.1" + checksum: 10/c25946fee29604f5aa24ea059bc3cc7bc4c8cdaf26db1ed6ffa4f28e37f5193cc4e868650c807d89caff4123e44d07b58200d4cb5960ebdb7d66531509d76359 + languageName: node + linkType: hard + "@jest/environment@npm:^29.7.0": version: 29.7.0 resolution: "@jest/environment@npm:29.7.0" @@ -4768,6 +4876,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/expect-utils@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + checksum: 10/3f0337ec791d669cacd07594521f2da71b956712dfd0c0007253dd5e886ef640df510af1357878a80ac56f09d3db9fd68e3db66959f0fdb3add5f551dd7e0f35 + languageName: node + linkType: hard + "@jest/expect-utils@npm:^29.7.0": version: 29.7.0 resolution: "@jest/expect-utils@npm:29.7.0" @@ -4777,6 +4894,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/expect@npm:30.4.1" + dependencies: + expect: "npm:30.4.1" + jest-snapshot: "npm:30.4.1" + checksum: 10/40ae0317a3590ced7a7fd21c49e6b1af6b122e6a83822e643af83f02034dfed6485248cae08d6bcf9380039ba3824ac56db18478712c64ddf5f709ee23cf30cd + languageName: node + linkType: hard + "@jest/expect@npm:^29.7.0": version: 29.7.0 resolution: "@jest/expect@npm:29.7.0" @@ -4787,6 +4914,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/fake-timers@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@sinonjs/fake-timers": "npm:^15.4.0" + "@types/node": "npm:*" + jest-message-util: "npm:30.4.1" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + checksum: 10/bc7aff23548395d6e7957bc24f699f921a9616f2357ab49616b0468c7b5e94e6ac4cbdd45d306f1a5d7f72e2a055294f52be3666e4c1da7c137874c5b226e1c6 + languageName: node + linkType: hard + "@jest/fake-timers@npm:^29.7.0": version: 29.7.0 resolution: "@jest/fake-timers@npm:29.7.0" @@ -4801,6 +4942,25 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.1.0": + version: 30.1.0 + resolution: "@jest/get-type@npm:30.1.0" + checksum: 10/e2a95fbb49ce2d15547db8af5602626caf9b05f62a5e583b4a2de9bd93a2bfe7175f9bbb2b8a5c3909ce261d467b6991d7265bb1d547cb60e7e97f571f361a70 + languageName: node + linkType: hard + +"@jest/globals@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/globals@npm:30.4.1" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/expect": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + jest-mock: "npm:30.4.1" + checksum: 10/5fe04b9c3b97f0061e4464201ee0dd674dd958843eb80542791d1c576c51f12aaa3f3b369e136d5fd3f8c716f9c9bbfbb76491a3cbc3c4efb3cc71063f909132 + languageName: node + linkType: hard + "@jest/globals@npm:^29.7.0": version: 29.7.0 resolution: "@jest/globals@npm:29.7.0" @@ -4813,6 +4973,52 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.4.0": + version: 30.4.0 + resolution: "@jest/pattern@npm:30.4.0" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.4.0" + checksum: 10/4fb1db0e586713708d2fcd79059315600978608483ef2d80e04a0a59b20b0d8de0d3f47cad950ff90bfb9ea3cb788709ee3d1eb225734e4dbf1c4b743c93d204 + languageName: node + linkType: hard + +"@jest/reporters@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/reporters@npm:30.4.1" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:30.4.1" + "@jest/test-result": "npm:30.4.1" + "@jest/transform": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@jridgewell/trace-mapping": "npm:^0.3.25" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + collect-v8-coverage: "npm:^1.0.2" + exit-x: "npm:^0.2.2" + glob: "npm:^10.5.0" + graceful-fs: "npm:^4.2.11" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^5.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:30.4.1" + jest-util: "npm:30.4.1" + jest-worker: "npm:30.4.1" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.2" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/e14e3717c9fe49004b6406cc53554f90954345917bb5f077d23e3a2cecde26d90493e1522d95f63b6c14ef06fb63d45b695ac6400d63b96fecc7a80d1ef83f4d + languageName: node + linkType: hard + "@jest/reporters@npm:^29.7.0": version: 29.7.0 resolution: "@jest/reporters@npm:29.7.0" @@ -4850,6 +5056,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/schemas@npm:30.4.1" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10/86e62c8fd8fc77535085f1ede3a416430a3740f78b8f88ec7d0ee4516b22daf3326ffc1ade9d5f7839bbde923aaf1b5ac430a42ed4bb1a38edc3de5005a58f51 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -4859,6 +5074,29 @@ __metadata: languageName: node linkType: hard +"@jest/snapshot-utils@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/snapshot-utils@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + natural-compare: "npm:^1.4.0" + checksum: 10/8f17768702153267388b3043f358027385e591ac4668699bfce3547cb8e08ac146a074913bcddf68c0a4f7155e24a6d582d27f4592f5c3bd5f9fbc3f9182ef78 + languageName: node + linkType: hard + +"@jest/source-map@npm:30.0.1": + version: 30.0.1 + resolution: "@jest/source-map@npm:30.0.1" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.25" + callsites: "npm:^3.1.0" + graceful-fs: "npm:^4.2.11" + checksum: 10/161b27cdf8d9d80fd99374d55222b90478864c6990514be6ebee72b7184a034224c9aceed12c476f3a48d48601bf8ed2e0c047a5a81bd907dc192ebe71365ed4 + languageName: node + linkType: hard + "@jest/source-map@npm:^29.6.3": version: 29.6.3 resolution: "@jest/source-map@npm:29.6.3" @@ -4870,6 +5108,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/test-result@npm:30.4.1" + dependencies: + "@jest/console": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + collect-v8-coverage: "npm:^1.0.2" + checksum: 10/c420182d72cef64827981230b4c84b2de3f4312067e7baf1e3e13c501dc57f73faa09fed1a5ed1a6e96bc29f6c67ac2c14de5f973945f14853010729678cb44a + languageName: node + linkType: hard + "@jest/test-result@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-result@npm:29.7.0" @@ -4882,6 +5132,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/test-sequencer@npm:30.4.1" + dependencies: + "@jest/test-result": "npm:30.4.1" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.4.1" + slash: "npm:^3.0.0" + checksum: 10/d911ef0c527c402d41537aa5f9754725a58732c1c6616401454633fd45729da0b2f01b4c50322b1b789a9f2d4edf3a24aecb0b2e4ca4d873c4335894b63bc5b0 + languageName: node + linkType: hard + "@jest/test-sequencer@npm:^29.7.0": version: 29.7.0 resolution: "@jest/test-sequencer@npm:29.7.0" @@ -4894,6 +5156,28 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/transform@npm:30.4.1" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/types": "npm:30.4.1" + "@jridgewell/trace-mapping": "npm:^0.3.25" + babel-plugin-istanbul: "npm:^7.0.1" + chalk: "npm:^4.1.2" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.4.1" + jest-regex-util: "npm:30.4.0" + jest-util: "npm:30.4.1" + pirates: "npm:^4.0.7" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^5.0.1" + checksum: 10/7b570451f6c26360f1b852c2281dcc4e36fe685dbc159cf5eabf83d49d6aae4569f444d38f3afb5b3b6e0b809eb41b65f3145c0cac5fee3eec9c9b178fb1f0ea + languageName: node + linkType: hard + "@jest/transform@npm:^29.7.0": version: 29.7.0 resolution: "@jest/transform@npm:29.7.0" @@ -4917,6 +5201,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.4.1": + version: 30.4.1 + resolution: "@jest/types@npm:30.4.1" + dependencies: + "@jest/pattern": "npm:30.4.0" + "@jest/schemas": "npm:30.4.1" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10/cc0999508613487c6d0f55661cd342ebe7cfe579fa9917534b94310204358f03f94524f70f00b4fe3c6dd2ccd0fd44657615a1b9f420ab310d68b43964bff87c + languageName: node + linkType: hard + "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -4988,7 +5287,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -5439,12 +5738,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5476,14 +5775,14 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5517,10 +5816,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5538,10 +5837,10 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5558,12 +5857,12 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5582,11 +5881,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5602,10 +5901,10 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5628,10 +5927,10 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5649,11 +5948,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5661,7 +5960,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^11.1.0, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^11.2.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -5670,12 +5969,12 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.5.5" "@metamask/accounts-controller": "npm:^39.0.5" - "@metamask/assets-controllers": "npm:^109.4.1" + "@metamask/assets-controllers": "npm:^110.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^6.5.0" + "@metamask/core-backend": "npm:^7.0.0" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/keyring-internal-api": "npm:^11.0.1" @@ -5690,18 +5989,18 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" p-limit: "npm:^3.1.0" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5709,7 +6008,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^109.4.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^110.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -5729,7 +6028,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^6.5.0" + "@metamask/core-backend": "npm:^7.0.0" "@metamask/eth-query": "npm:^4.0.0" "@metamask/ethjs-provider-http": "npm:^0.3.0" "@metamask/keyring-api": "npm:^23.5.0" @@ -5752,12 +6051,12 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/uuid": "npm:^8.3.0" @@ -5766,14 +6065,14 @@ __metadata: bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" multiformats: "npm:^9.9.0" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" single-call-balance-checker-abi: "npm:^1.0.0" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5831,11 +6130,11 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5874,11 +6173,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5896,15 +6195,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5919,10 +6218,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -5932,7 +6231,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^77.6.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^77.8.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -5942,13 +6241,13 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^39.0.5" - "@metamask/assets-controller": "npm:^11.1.0" - "@metamask/assets-controllers": "npm:^109.4.1" + "@metamask/assets-controller": "npm:^11.2.0" + "@metamask/assets-controllers": "npm:^110.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" - "@metamask/gas-fee-controller": "npm:^26.2.4" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" @@ -5959,18 +6258,18 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -5986,9 +6285,9 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.5" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^77.6.0" + "@metamask/bridge-controller": "npm:^77.8.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/gas-fee-controller": "npm:^26.2.4" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" @@ -5996,17 +6295,17 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6032,10 +6331,10 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/eslint": "npm:^8.44.7" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6055,11 +6354,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6067,7 +6366,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@npm:^3.1.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": +"@metamask/chomp-api-service@npm:^4.0.0, @metamask/chomp-api-service@workspace:packages/chomp-api-service": version: 0.0.0-use.local resolution: "@metamask/chomp-api-service@workspace:packages/chomp-api-service" dependencies: @@ -6079,11 +6378,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6103,10 +6402,10 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6122,11 +6421,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6142,15 +6441,15 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/contract-metadata": "npm:^2.4.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^6.5.0" + "@metamask/core-backend": "npm:^7.0.0" "@metamask/keyring-api": "npm:^23.5.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6169,12 +6468,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6191,11 +6490,11 @@ __metadata: "@metamask/json-rpc-engine": "npm:^10.5.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6203,7 +6502,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/config-registry-controller@workspace:packages/config-registry-controller": +"@metamask/config-registry-controller@npm:^0.4.1, @metamask/config-registry-controller@workspace:packages/config-registry-controller": version: 0.0.0-use.local resolution: "@metamask/config-registry-controller@workspace:packages/config-registry-controller" dependencies: @@ -6219,12 +6518,12 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6240,11 +6539,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6293,7 +6592,7 @@ __metadata: "@spruceid/siwe-parser": "npm:2.1.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" @@ -6301,11 +6600,11 @@ __metadata: deepmerge: "npm:^4.2.2" eth-ens-namehash: "npm:^2.0.8" fast-deep-equal: "npm:^3.1.3" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6315,7 +6614,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/core-backend@npm:^6.5.0, @metamask/core-backend@workspace:packages/core-backend": +"@metamask/core-backend@npm:^7.0.0, @metamask/core-backend@workspace:packages/core-backend": version: 0.0.0-use.local resolution: "@metamask/core-backend@workspace:packages/core-backend" dependencies: @@ -6329,12 +6628,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6361,13 +6660,14 @@ __metadata: "@metamask/skills": "npm:^0.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" "@types/node": "npm:^16.18.54" "@types/semver": "npm:^7" "@typescript-eslint/eslint-plugin": "npm:^8.48.0" "@typescript-eslint/parser": "npm:^8.48.0" "@yarnpkg/types": "npm:^4.0.0" + bats: "npm:^1.13.0" comment-json: "npm:^4.5.1" eslint: "npm:^9.39.1" eslint-config-prettier: "npm:^9.1.0" @@ -6380,14 +6680,13 @@ __metadata: eslint-plugin-promise: "npm:^7.1.0" execa: "npm:^5.0.0" isomorphic-fetch: "npm:^3.0.0" - jest: "npm:^29.7.0" - jest-silent-reporter: "npm:^0.5.0" + jest: "npm:^30.4.2" + jest-silent-reporter: "npm:^0.6.0" knip: "npm:^6.23.0" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" oxfmt: "npm:^0.44.0" prettier: "npm:^3.3.3" - prettier-2: "npm:prettier@^2.8.8" rimraf: "npm:^5.0.5" semver: "npm:^7.6.3" simple-git-hooks: "npm:^2.8.0" @@ -6440,10 +6739,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6483,13 +6782,13 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6507,15 +6806,15 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" klona: "npm:^2.0.6" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6534,10 +6833,10 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -6557,11 +6856,11 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6581,11 +6880,11 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" punycode: "npm:^2.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6666,12 +6965,12 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/json-rpc-random-id": "npm:^1.0.1" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" json-rpc-random-id: "npm:^1.0.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -6738,10 +7037,10 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" klona: "npm:^2.0.6" safe-stable-stringify: "npm:^2.4.3" tsx: "npm:^4.20.5" @@ -6775,12 +7074,12 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" @@ -7016,16 +7315,16 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/unzipper": "npm:^0.10.10" "@types/yargs": "npm:^17.0.32" "@types/yargs-parser": "npm:^21.0.3" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" minipass: "npm:^7.1.2" nock: "npm:^13.3.1" tar: "npm:^7.4.3" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7039,7 +7338,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/gas-fee-controller@npm:^26.2.4, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": +"@metamask/gas-fee-controller@npm:^26.3.0, @metamask/gas-fee-controller@workspace:packages/gas-fee-controller": version: 0.0.0-use.local resolution: "@metamask/gas-fee-controller@workspace:packages/gas-fee-controller" dependencies: @@ -7055,15 +7354,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/jest-when": "npm:^2.7.3" "@types/uuid": "npm:^8.3.0" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-when: "npm:^3.4.2" + jest: "npm:^30.4.2" + jest-when: "npm:^3.7.0" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7091,13 +7390,13 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7114,10 +7413,10 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7132,10 +7431,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7158,12 +7457,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" klona: "npm:^2.0.6" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typescript: "npm:~5.3.3" @@ -7179,13 +7478,13 @@ __metadata: "@metamask/safe-event-emitter": "npm:^3.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/readable-stream": "npm:^2.3.0" deepmerge: "npm:^4.2.2" extension-port-stream: "npm:^3.0.0" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" readable-stream: "npm:^3.6.2" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7242,15 +7541,15 @@ __metadata: "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" ethereumjs-wallet: "npm:^1.0.1" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7383,11 +7682,11 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7405,10 +7704,10 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7428,12 +7727,12 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7449,14 +7748,14 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/yargs": "npm:^17.0.32" deepmerge: "npm:^4.2.2" execa: "npm:^5.0.0" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" oxfmt: "npm:^0.44.0" prettier: "npm:^3.3.3" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" yargs: "npm:^17.7.2" @@ -7495,11 +7794,11 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7516,7 +7815,7 @@ __metadata: languageName: node linkType: hard -"@metamask/money-account-api-data-service@npm:^0.2.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": +"@metamask/money-account-api-data-service@npm:^0.3.0, @metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service": version: 0.0.0-use.local resolution: "@metamask/money-account-api-data-service@workspace:packages/money-account-api-data-service" dependencies: @@ -7528,11 +7827,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7551,17 +7850,17 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" - "@metamask/money-account-api-data-service": "npm:^0.2.0" + "@metamask/money-account-api-data-service": "npm:^0.3.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7583,11 +7882,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7602,7 +7901,7 @@ __metadata: "@metamask/authenticated-user-storage": "npm:^3.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/chomp-api-service": "npm:^3.1.0" + "@metamask/chomp-api-service": "npm:^4.0.0" "@metamask/delegation-controller": "npm:^3.0.2" "@metamask/delegation-core": "npm:^2.2.1" "@metamask/delegation-deployments": "npm:^1.4.0" @@ -7611,11 +7910,11 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7650,13 +7949,13 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7691,11 +7990,11 @@ __metadata: "@open-rpc/meta-schema": "npm:^1.14.6" "@open-rpc/schema-utils-js": "npm:^2.0.5" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" jsonschema: "npm:^1.4.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7720,15 +8019,15 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@solana/addresses": "npm:^2.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7754,12 +8053,12 @@ __metadata: "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7778,11 +8077,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -7804,11 +8103,11 @@ __metadata: "@metamask/network-enablement-controller": "npm:^5.6.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7838,7 +8137,7 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/jest-when": "npm:^2.7.3" "@types/lodash": "npm:^4.14.191" "@types/node-fetch": "npm:^2.6.12" @@ -7847,15 +8146,15 @@ __metadata: deepmerge: "npm:^4.2.2" fast-deep-equal: "npm:^3.1.3" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" - jest-when: "npm:^3.4.2" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" + jest-when: "npm:^3.7.0" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" node-fetch: "npm:^2.7.0" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7871,20 +8170,21 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/config-registry-controller": "npm:^0.4.1" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-api": "npm:^23.5.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/multichain-network-controller": "npm:^3.2.1" "@metamask/network-controller": "npm:^34.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7920,7 +8220,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/lodash": "npm:^4.14.191" "@types/readable-stream": "npm:^2.3.0" "@types/semver": "npm:^7" @@ -7928,13 +8228,13 @@ __metadata: contentful: "npm:^10.15.0" deepmerge: "npm:^4.2.2" firebase: "npm:^11.2.0" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" semver: "npm:^7.6.3" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -7977,12 +8277,12 @@ __metadata: "@noble/curves": "npm:^1.9.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8023,13 +8323,13 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8048,12 +8348,12 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/deep-freeze-strict": "npm:^1.1.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deep-freeze-strict: "npm:^1.1.1" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nanoid: "npm:^3.3.8" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8079,18 +8379,18 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" "@nktkas/hyperliquid": "npm:^0.33.1" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8111,18 +8411,18 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/punycode": "npm:^2.1.0" deepmerge: "npm:^4.2.2" ethereum-cryptography: "npm:^2.1.2" fastest-levenshtein: "npm:^1.0.16" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" punycode: "npm:^2.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8144,7 +8444,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/node": "npm:^16.18.54" "@types/npm-which": "npm:^3" "@types/react": "npm:^19.0.0" @@ -8152,12 +8452,12 @@ __metadata: deepmerge: "npm:^4.2.2" execa: "npm:^5.0.0" glob: "npm:^13.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" npm-which: "npm:^3.0.1" prism-react-renderer: "npm:^2.4.1" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" ts-morph: "npm:^28.0.0" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" @@ -8177,12 +8477,12 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/uuid": "npm:^8.3.0" deepmerge: "npm:^4.2.2" fast-json-stable-stringify: "npm:^2.1.0" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8210,10 +8510,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8238,15 +8538,15 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8276,16 +8576,16 @@ __metadata: "@noble/ciphers": "npm:^1.3.0" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" ethers: "npm:^6.12.0" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" loglevel: "npm:^1.8.1" nock: "npm:^13.3.1" siwe: "npm:^2.3.2" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8329,11 +8629,11 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8351,10 +8651,10 @@ __metadata: "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8370,13 +8670,13 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" - "@tanstack/query-core": "npm:^4.43.0" - "@tanstack/react-query": "npm:^4.43.0" + "@tanstack/query-core": "npm:^5.62.16" + "@tanstack/react-query": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8404,11 +8704,11 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8448,11 +8748,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8470,7 +8770,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^10.0.3, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^10.1.0, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -8489,14 +8789,14 @@ __metadata: "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/elliptic": "npm:^6" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/json-stable-stringify-without-jsonify": "npm:^1.0.2" async-mutex: "npm:^0.5.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8517,14 +8817,14 @@ __metadata: "@metamask/swappable-obj-proxy": "npm:^2.3.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8532,7 +8832,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/sentinel-api-service@workspace:packages/sentinel-api-service": +"@metamask/sentinel-api-service@npm:^1.0.0, @metamask/sentinel-api-service@workspace:packages/sentinel-api-service": version: 0.0.0-use.local resolution: "@metamask/sentinel-api-service@workspace:packages/sentinel-api-service" dependencies: @@ -8544,11 +8844,11 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8568,15 +8868,15 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/signature-controller": "npm:^39.2.7" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" cockatiel: "npm:^3.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8602,12 +8902,12 @@ __metadata: "@metamask/network-controller": "npm:^34.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" jsonschema: "npm:^1.4.1" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8652,16 +8952,16 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8686,11 +8986,11 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -8827,10 +9127,10 @@ __metadata: "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/superstruct": "npm:^3.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8845,10 +9145,10 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/local-node-utils": "npm:^1.0.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8871,10 +9171,10 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8890,10 +9190,10 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8911,14 +9211,14 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.8" "@metamask/profile-sync-controller": "npm:^28.3.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -8958,7 +9258,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^69.2.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^69.2.1, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -8975,11 +9275,11 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/core-backend": "npm:^6.5.0" + "@metamask/core-backend": "npm:^7.0.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-json-rpc-provider": "npm:^6.0.1" "@metamask/ethjs-provider-http": "npm:^0.3.0" - "@metamask/gas-fee-controller": "npm:^26.2.4" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" @@ -8989,7 +9289,7 @@ __metadata: "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/bn.js": "npm:^5.1.5" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/node": "npm:^16.18.54" async-mutex: "npm:^0.5.0" bignumber.js: "npm:^9.1.2" @@ -8998,11 +9298,11 @@ __metadata: eth-method-registry: "npm:^4.0.0" fast-json-patch: "npm:^3.1.1" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-jsdom: "npm:^29.7.0" + jest: "npm:^30.4.2" + jest-environment-jsdom: "npm:^30.4.1" lodash: "npm:^4.17.21" nock: "npm:^13.3.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9021,29 +9321,30 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^11.1.0" - "@metamask/assets-controllers": "npm:^109.4.1" + "@metamask/assets-controller": "npm:^11.2.0" + "@metamask/assets-controllers": "npm:^110.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/gas-fee-controller": "npm:^26.2.4" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" "@metamask/ramps-controller": "npm:^17.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/sentinel-api-service": "npm:^1.0.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9061,23 +9362,23 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/eth-block-tracker": "npm:^15.0.0" "@metamask/eth-query": "npm:^4.0.0" - "@metamask/gas-fee-controller": "npm:^26.2.4" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/polling-controller": "npm:^16.0.8" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.1.0" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" bn.js: "npm:^5.2.1" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" lodash: "npm:^4.17.21" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" @@ -9138,17 +9439,17 @@ __metadata: "@metamask/storage-service": "npm:^1.0.2" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" - "@metamask/wallet": "npm:^7.0.1" + "@metamask/wallet": "npm:^8.1.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/better-sqlite3": "npm:^7.6.13" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" better-sqlite3: "npm:^12.9.0" deepmerge: "npm:^4.2.2" immer: "npm:^9.0.6" - jest: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + jest: "npm:^30.4.2" + jest-environment-node: "npm:^30.4.1" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" bin: @@ -9174,22 +9475,22 @@ __metadata: "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^4.43.0" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" "@types/react": "npm:^19.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" prism-react-renderer: "npm:^2.4.1" raw-loader: "npm:^4.0.2" react: "npm:^19.0.0" react-dom: "npm:^19.0.0" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" typescript: "npm:~5.3.3" languageName: unknown linkType: soft -"@metamask/wallet@npm:^7.0.1, @metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^8.1.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: @@ -9201,21 +9502,22 @@ __metadata: "@metamask/browser-passworder": "npm:^6.0.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^34.0.0" "@metamask/passkey-controller": "npm:^3.0.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/scure-bip39": "npm:^2.1.1" - "@metamask/seedless-onboarding-controller": "npm:^10.0.3" + "@metamask/seedless-onboarding-controller": "npm:^10.1.0" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.2.0" + "@metamask/transaction-controller": "npm:^69.2.1" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" - ts-jest: "npm:^29.2.5" + 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" @@ -9245,7 +9547,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^0.2.3": +"@napi-rs/wasm-runtime@npm:^0.2.11, @napi-rs/wasm-runtime@npm:^0.2.3": version: 0.2.12 resolution: "@napi-rs/wasm-runtime@npm:0.2.12" dependencies: @@ -10411,6 +10713,13 @@ __metadata: languageName: node linkType: hard +"@pkgr/core@npm:^0.2.9": + version: 0.2.9 + resolution: "@pkgr/core@npm:0.2.9" + checksum: 10/bb2fb86977d63f836f8f5b09015d74e6af6488f7a411dcd2bfdca79d76b5a681a9112f41c45bdf88a9069f049718efc6f3900d7f1de66a2ec966068308ae517f + languageName: node + linkType: hard + "@pnpm/config.env-replace@npm:^1.1.0": version: 1.1.0 resolution: "@pnpm/config.env-replace@npm:1.1.0" @@ -10611,6 +10920,13 @@ __metadata: languageName: node linkType: hard +"@sinclair/typebox@npm:^0.34.0": + version: 0.34.48 + resolution: "@sinclair/typebox@npm:0.34.48" + checksum: 10/186eebb338255db7cfd77c2f94be0ad91816c7b5ee994c3adb95e0474ae98b769574c2b6b1f26a81613d7148ed20b11e02528f4263d8d95e3ca8dcf8faaf5306 + languageName: node + linkType: hard + "@sindresorhus/is@npm:^4.6.0": version: 4.6.0 resolution: "@sindresorhus/is@npm:4.6.0" @@ -10625,7 +10941,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0": +"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -10643,6 +10959,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^15.4.0": + version: 15.4.0 + resolution: "@sinonjs/fake-timers@npm:15.4.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.1" + checksum: 10/3960a9fe065f38a4228c66d184eeb101e8a6af9cbfc8454dd5d45ac397201da72134048d4e808a25993494885b172dd6deecdad9949bbf4c1d3a220ef561f6cc + languageName: node + linkType: hard + "@slorber/remark-comment@npm:^1.0.0": version: 1.0.0 resolution: "@slorber/remark-comment@npm:1.0.0" @@ -10952,43 +11277,28 @@ __metadata: languageName: node linkType: hard -"@tanstack/query-core@npm:4.43.0, @tanstack/query-core@npm:^4.43.0": - version: 4.43.0 - resolution: "@tanstack/query-core@npm:4.43.0" - checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 +"@tanstack/query-core@npm:5.101.2, @tanstack/query-core@npm:^5.62.16": + version: 5.101.2 + resolution: "@tanstack/query-core@npm:5.101.2" + checksum: 10/567af5e3c21628745a08c1a5054d838597bc620dcbbeabe20cdd6ccffdcca85a8d38128f6e80829b6e04d10f79ca793a7dd8fddc8a3d6bef10c2d580ae214c7d languageName: node linkType: hard -"@tanstack/query-core@npm:^5.62.16": - version: 5.90.20 - resolution: "@tanstack/query-core@npm:5.90.20" - checksum: 10/25e38f4382442bc15e0f6cce8d787e9df8d8822c61d3f3e9427e89e01b1e2506f848292e086dae29aeb55f8ce71b097c34221f3c5eda37fb4a688b5ceca5d1b3 +"@tanstack/query-core@npm:^4.43.0": + version: 4.43.0 + resolution: "@tanstack/query-core@npm:4.43.0" + checksum: 10/c2a5a151c7adaea8311e01a643255f31946ae3164a71567ba80048242821ae14043f13f5516b695baebe5ea7e4b2cf717fd60908a929d18a5c5125fee925ff67 languageName: node linkType: hard -"@tanstack/react-query@npm:^4.43.0": - version: 4.43.0 - resolution: "@tanstack/react-query@npm:4.43.0" +"@tanstack/react-query@npm:^5.62.16": + version: 5.101.2 + resolution: "@tanstack/react-query@npm:5.101.2" dependencies: - "@tanstack/query-core": "npm:4.43.0" - use-sync-external-store: "npm:^1.6.0" + "@tanstack/query-core": "npm:5.101.2" peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-native: "*" - peerDependenciesMeta: - react-dom: - optional: true - react-native: - optional: true - checksum: 10/23f9d18d130fa2a1238d8fba8bc914c67e33753b7fc3a3c7856354a9873c4cbc5d18ce24dbf6364ecf86b8ea787575e1e60998ea75baa2b9e9647ad4b9127e10 - languageName: node - linkType: hard - -"@tootallnate/once@npm:2": - version: 2.0.0 - resolution: "@tootallnate/once@npm:2.0.0" - checksum: 10/ad87447820dd3f24825d2d947ebc03072b20a42bfc96cbafec16bff8bbda6c1a81fcb0be56d5b21968560c5359a0af4038a68ba150c3e1694fe4c109a063bed8 + react: ^18 || ^19 + checksum: 10/0837c176b6afb01e3632010bbd8bdfeee7f46c4a25a50ed2ba9ddddce11c34431161d80dd8e53a2f55190c2313bbfb22a84a77308e6ccf29877b4818de68c4b3 languageName: node linkType: hard @@ -11104,7 +11414,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.1.14": +"@types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -11368,7 +11678,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1": +"@types/istanbul-lib-coverage@npm:*, @types/istanbul-lib-coverage@npm:^2.0.0, @types/istanbul-lib-coverage@npm:^2.0.1, @types/istanbul-lib-coverage@npm:^2.0.6": version: 2.0.6 resolution: "@types/istanbul-lib-coverage@npm:2.0.6" checksum: 10/3feac423fd3e5449485afac999dcfcb3d44a37c830af898b689fadc65d26526460bedb889db278e0d4d815a670331796494d073a10ee6e3a6526301fe7415778 @@ -11384,7 +11694,7 @@ __metadata: languageName: node linkType: hard -"@types/istanbul-reports@npm:^3.0.0": +"@types/istanbul-reports@npm:^3.0.0, @types/istanbul-reports@npm:^3.0.4": version: 3.0.4 resolution: "@types/istanbul-reports@npm:3.0.4" dependencies: @@ -11402,7 +11712,17 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:*, @types/jest@npm:^29.5.14": +"@types/jest@npm:*, @types/jest@npm:^30.0.0": + version: 30.0.0 + resolution: "@types/jest@npm:30.0.0" + dependencies: + expect: "npm:^30.0.0" + pretty-format: "npm:^30.0.0" + checksum: 10/cdeaa924c68b5233d9ff92861a89e7042df2b0f197633729bcf3a31e65bd4e9426e751c5665b5ac2de0b222b33f100a5502da22aefce3d2c62931c715e88f209 + languageName: node + linkType: hard + +"@types/jest@npm:^29.5.14": version: 29.5.14 resolution: "@types/jest@npm:29.5.14" dependencies: @@ -11412,14 +11732,14 @@ __metadata: languageName: node linkType: hard -"@types/jsdom@npm:^20.0.0": - version: 20.0.1 - resolution: "@types/jsdom@npm:20.0.1" +"@types/jsdom@npm:^21.1.7": + version: 21.1.7 + resolution: "@types/jsdom@npm:21.1.7" dependencies: "@types/node": "npm:*" "@types/tough-cookie": "npm:*" parse5: "npm:^7.0.0" - checksum: 10/15fbb9a0bfb4a5845cf6e795f2fd12400aacfca53b8c7e5bca4a3e5e8fa8629f676327964d64258aefb127d2d8a2be86dad46359efbfca0e8c9c2b790e7f8a88 + checksum: 10/a5ee54aec813ac928ef783f69828213af4d81325f584e1fe7573a9ae139924c40768d1d5249237e62d51b9a34ed06bde059c86c6b0248d627457ec5e5d532dfa languageName: node linkType: hard @@ -11701,7 +12021,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -11777,12 +12097,12 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.8": - version: 17.0.33 - resolution: "@types/yargs@npm:17.0.33" +"@types/yargs@npm:^17.0.32, @types/yargs@npm:^17.0.33, @types/yargs@npm:^17.0.8": + version: 17.0.35 + resolution: "@types/yargs@npm:17.0.35" dependencies: "@types/yargs-parser": "npm:*" - checksum: 10/16f6681bf4d99fb671bf56029141ed01db2862e3db9df7fc92d8bea494359ac96a1b4b1c35a836d1e95e665fb18ad753ab2015fc0db663454e8fd4e5d5e2ef91 + checksum: 10/47bcd4476a4194ea11617ea71cba8a1eddf5505fc39c44336c1a08d452a0de4486aedbc13f47a017c8efbcb5a8aa358d976880663732ebcbc6dbcbbecadb0581 languageName: node linkType: hard @@ -11921,13 +12241,148 @@ __metadata: languageName: node linkType: hard -"@ungap/structured-clone@npm:^1.0.0": +"@ungap/structured-clone@npm:^1.0.0, @ungap/structured-clone@npm:^1.3.0": version: 1.3.1 resolution: "@ungap/structured-clone@npm:1.3.1" checksum: 10/64df206f50aef71c176f9059c1b29e1694821419c6728c446ecf39c80a811eeef156668bf51421b676494a12fd0129ccf09a44f0c641f13c27f50d5f0db6de4e languageName: node linkType: hard +"@unrs/resolver-binding-android-arm-eabi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.11.1" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-android-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-darwin-arm64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-darwin-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-freebsd-x64@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" + dependencies: + "@napi-rs/wasm-runtime": "npm:^0.2.11" + conditions: cpu=wasm32 + languageName: node + linkType: hard + +"@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + +"@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": + version: 1.11.1 + resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@vercel/stega@npm:^0.1.2": version: 0.1.2 resolution: "@vercel/stega@npm:0.1.2" @@ -12116,13 +12571,6 @@ __metadata: languageName: node linkType: hard -"abab@npm:^2.0.6": - version: 2.0.6 - resolution: "abab@npm:2.0.6" - checksum: 10/ebe95d7278999e605823fc515a3b05d689bc72e7f825536e73c95ebf621636874c6de1b749b3c4bf866b96ccd4b3a2802efa313d0e45ad51a413c8c73247db20 - languageName: node - linkType: hard - "abbrev@npm:^2.0.0": version: 2.0.0 resolution: "abbrev@npm:2.0.0" @@ -12179,16 +12627,6 @@ __metadata: languageName: node linkType: hard -"acorn-globals@npm:^7.0.0": - version: 7.0.1 - resolution: "acorn-globals@npm:7.0.1" - dependencies: - acorn: "npm:^8.1.0" - acorn-walk: "npm:^8.0.2" - checksum: 10/2a2998a547af6d0db5f0cdb90acaa7c3cbca6709010e02121fb8b8617c0fbd8bab0b869579903fde358ac78454356a14fadcc1a672ecb97b04b1c2ccba955ce8 - languageName: node - linkType: hard - "acorn-import-phases@npm:^1.0.3": version: 1.0.4 resolution: "acorn-import-phases@npm:1.0.4" @@ -12207,7 +12645,7 @@ __metadata: languageName: node linkType: hard -"acorn-walk@npm:^8.0.0, acorn-walk@npm:^8.0.2": +"acorn-walk@npm:^8.0.0": version: 8.3.5 resolution: "acorn-walk@npm:8.3.5" dependencies: @@ -12216,7 +12654,7 @@ __metadata: languageName: node linkType: hard -"acorn@npm:^8.0.0, acorn@npm:^8.0.4, acorn@npm:^8.1.0, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0, acorn@npm:^8.8.1": +"acorn@npm:^8.0.0, acorn@npm:^8.0.4, acorn@npm:^8.11.0, acorn@npm:^8.15.0, acorn@npm:^8.16.0": version: 8.16.0 resolution: "acorn@npm:8.16.0" bin: @@ -12253,21 +12691,10 @@ __metadata: languageName: node linkType: hard -"agent-base@npm:6": - version: 6.0.2 - resolution: "agent-base@npm:6.0.2" - dependencies: - debug: "npm:4" - checksum: 10/21fb903e0917e5cb16591b4d0ef6a028a54b83ac30cd1fca58dece3d4e0990512a8723f9f83130d88a41e2af8b1f7be1386fda3ea2d181bb1a62155e75e95e23 - languageName: node - linkType: hard - -"agent-base@npm:^7.0.2, agent-base@npm:^7.1.0, agent-base@npm:^7.1.1": - version: 7.1.1 - resolution: "agent-base@npm:7.1.1" - dependencies: - debug: "npm:^4.3.4" - checksum: 10/c478fec8f79953f118704d007a38f2a185458853f5c45579b9669372bd0e12602e88dc2ad0233077831504f7cd6fcc8251c383375bba5eaaf563b102938bda26 +"agent-base@npm:^7.1.0, agent-base@npm:^7.1.1, agent-base@npm:^7.1.2": + version: 7.1.4 + resolution: "agent-base@npm:7.1.4" + checksum: 10/79bef167247789f955aaba113bae74bf64aa1e1acca4b1d6bb444bdf91d82c3e07e9451ef6a6e2e35e8f71a6f97ce33e3d855a5328eb9fad1bc3cc4cfd031ed8 languageName: node linkType: hard @@ -12445,7 +12872,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -12466,7 +12893,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -12635,6 +13062,23 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:30.4.1": + version: 30.4.1 + resolution: "babel-jest@npm:30.4.1" + dependencies: + "@jest/transform": "npm:30.4.1" + "@types/babel__core": "npm:^7.20.5" + babel-plugin-istanbul: "npm:^7.0.1" + babel-preset-jest: "npm:30.4.0" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.11.0 || ^8.0.0-0 + checksum: 10/f739152bee60b368b27676441c54e235b49bca10329bb6395b54cca5982ced1f7a5a2c504e406e1082aac3dc68ab518771c9de62cf66ffe00993ad071a58fd1b + languageName: node + linkType: hard + "babel-jest@npm:^29.7.0": version: 29.7.0 resolution: "babel-jest@npm:29.7.0" @@ -12687,6 +13131,28 @@ __metadata: languageName: node linkType: hard +"babel-plugin-istanbul@npm:^7.0.1": + version: 7.0.1 + resolution: "babel-plugin-istanbul@npm:7.0.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-instrument: "npm:^6.0.2" + test-exclude: "npm:^6.0.0" + checksum: 10/fe9f865f975aaa7a033de9ccb2b63fdcca7817266c5e98d3e02ac7ffd774c695093d215302796cb3770a71ef4574e7a9b298504c3c0c104cf4b48c8eda67b2a6 + languageName: node + linkType: hard + +"babel-plugin-jest-hoist@npm:30.4.0": + version: 30.4.0 + resolution: "babel-plugin-jest-hoist@npm:30.4.0" + dependencies: + "@types/babel__core": "npm:^7.20.5" + checksum: 10/112f984b3b4315f7ff15d5d17df7f5aa4b500e562c67c2eafcd9974af4369c17d50feed2f9c95cbcec32faba8ccb04f8b62828aca41a47d5fdedc532b49fbf19 + languageName: node + linkType: hard + "babel-plugin-jest-hoist@npm:^29.6.3": version: 29.6.3 resolution: "babel-plugin-jest-hoist@npm:29.6.3" @@ -12747,9 +13213,9 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.0.0": - version: 1.1.0 - resolution: "babel-preset-current-node-syntax@npm:1.1.0" +"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.2.0": + version: 1.2.0 + resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: "@babel/plugin-syntax-async-generators": "npm:^7.8.4" "@babel/plugin-syntax-bigint": "npm:^7.8.3" @@ -12767,8 +13233,20 @@ __metadata: "@babel/plugin-syntax-private-property-in-object": "npm:^7.14.5" "@babel/plugin-syntax-top-level-await": "npm:^7.14.5" peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/46331111ae72b7121172fd9e6a4a7830f651ad44bf26dbbf77b3c8a60a18009411a3eacb5e72274004290c110371230272109957d5224d155436b4794ead2f1b + "@babel/core": ^7.0.0 || ^8.0.0-0 + checksum: 10/3608fa671cfa46364ea6ec704b8fcdd7514b7b70e6ec09b1199e13ae73ed346c51d5ce2cb6d4d5b295f6a3f2cad1fdeec2308aa9e037002dd7c929194cc838ea + languageName: node + linkType: hard + +"babel-preset-jest@npm:30.4.0": + version: 30.4.0 + resolution: "babel-preset-jest@npm:30.4.0" + dependencies: + babel-plugin-jest-hoist: "npm:30.4.0" + babel-preset-current-node-syntax: "npm:^1.2.0" + peerDependencies: + "@babel/core": ^7.11.0 || ^8.0.0-beta.1 + checksum: 10/7fbdcaa1f24b2efbc1b658220df849a375858bd5e208cefcf53b116bca972b28565a0715521cc20bec41adbd20ff73b9dbbdea3634bd71f50062f0ce694a7159 languageName: node linkType: hard @@ -12861,6 +13339,15 @@ __metadata: languageName: node linkType: hard +"bats@npm:^1.13.0": + version: 1.13.0 + resolution: "bats@npm:1.13.0" + bin: + bats: bin/bats + checksum: 10/cc120033073f5ed798accfc27d2f45863204d03673d985fdb5d850246df6adf8007be4678511ec206e5a47b46f15801fa612ca7d8e16ccf6afa7a009286c1627 + languageName: node + linkType: hard + "bech32@npm:1.1.4": version: 1.1.4 resolution: "bech32@npm:1.1.4" @@ -13079,11 +13566,11 @@ __metadata: linkType: hard "brace-expansion@npm:^2.0.1, brace-expansion@npm:^2.0.2": - version: 2.1.1 - resolution: "brace-expansion@npm:2.1.1" + version: 2.1.2 + resolution: "brace-expansion@npm:2.1.2" dependencies: balanced-match: "npm:^1.0.0" - checksum: 10/4681c533dc4e6c77b3ad795b38683d297fd03c739a17bfb2a338529fa7dcf4540683a79dcd662905f4c5b0db7cfda18daafcd18dd1bbf7c3b076fe0c9c3487eb + checksum: 10/0e0f9b0df1f0809e9c326470e022eeb24334ddb54c43b1ac39f1d38f3cbaeb940794de1db826f5491843ac00d7932c43f10350a65ccd51fe7d05da627152f204 languageName: node linkType: hard @@ -13317,7 +13804,7 @@ __metadata: languageName: node linkType: hard -"callsites@npm:^3.0.0": +"callsites@npm:^3.0.0, callsites@npm:^3.1.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" checksum: 10/072d17b6abb459c2ba96598918b55868af677154bec7e73d222ef95a8fdb9bbf7dae96a8421085cdad8cd190d86653b5b6dc55a4484f2e5b2e27d5e0c3fc15b3 @@ -13341,7 +13828,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^6.2.0": +"camelcase@npm:^6.2.0, camelcase@npm:^6.3.0": version: 6.3.0 resolution: "camelcase@npm:6.3.0" checksum: 10/8c96818a9076434998511251dcb2761a94817ea17dbdc37f47ac080bd088fc62c7369429a19e2178b993497132c8cbcf5cc1f44ba963e76782ba469c0474938d @@ -13563,6 +14050,13 @@ __metadata: languageName: node linkType: hard +"ci-info@npm:^4.2.0": + version: 4.4.0 + resolution: "ci-info@npm:4.4.0" + checksum: 10/dfded0c630267d89660c8abb988ac8395a382bdfefedcc03e3e2858523312c5207db777c239c34774e3fcff11f015477c19d2ac8a58ea58aa476614a2e64f434 + languageName: node + linkType: hard + "cipher-base@npm:^1.0.0, cipher-base@npm:^1.0.1, cipher-base@npm:^1.0.3": version: 1.0.4 resolution: "cipher-base@npm:1.0.4" @@ -13573,13 +14067,27 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.0.0, cjs-module-lexer@npm:^1.3.1": +"cjs-module-lexer@npm:^1.0.0": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 + languageName: node + linkType: hard + +"cjs-module-lexer@npm:^1.3.1": version: 1.4.0 resolution: "cjs-module-lexer@npm:1.4.0" checksum: 10/b041096749792526120d8b8756929f8ef5dd4596502a0e1013f857e3027acd6091915fea77037921d70ee1a99988a100d994d3d3c2e323b04dd4c5ffd516cf13 languageName: node linkType: hard +"cjs-module-lexer@npm:^2.1.0": + version: 2.2.0 + resolution: "cjs-module-lexer@npm:2.2.0" + checksum: 10/fc8eb5c1919504366d8260a150d93c4e857740e770467dc59ca0cc34de4b66c93075559a5af65618f359187866b1be40e036f4e1a1bab2f1e06001c216415f74 + languageName: node + linkType: hard + "clean-css@npm:^5.2.2, clean-css@npm:^5.3.3, clean-css@npm:~5.3.2": version: 5.3.3 resolution: "clean-css@npm:5.3.3" @@ -13714,10 +14222,10 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.0": - version: 1.0.2 - resolution: "collect-v8-coverage@npm:1.0.2" - checksum: 10/30ea7d5c9ee51f2fdba4901d4186c5b7114a088ef98fd53eda3979da77eed96758a2cae81cc6d97e239aaea6065868cf908b24980663f7b7e96aa291b3e12fa4 +"collect-v8-coverage@npm:^1.0.0, collect-v8-coverage@npm:^1.0.2": + version: 1.0.3 + resolution: "collect-v8-coverage@npm:1.0.3" + checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 languageName: node linkType: hard @@ -14435,26 +14943,13 @@ __metadata: languageName: node linkType: hard -"cssom@npm:^0.5.0": - version: 0.5.0 - resolution: "cssom@npm:0.5.0" - checksum: 10/b502a315b1ce020a692036cc38cb36afa44157219b80deadfa040ab800aa9321fcfbecf02fd2e6ec87db169715e27978b4ab3701f916461e9cf7808899f23b54 - languageName: node - linkType: hard - -"cssom@npm:~0.3.6": - version: 0.3.8 - resolution: "cssom@npm:0.3.8" - checksum: 10/49eacc88077555e419646c0ea84ddc73c97e3a346ad7cb95e22f9413a9722d8964b91d781ce21d378bd5ae058af9a745402383fa4e35e9cdfd19654b63f892a9 - languageName: node - linkType: hard - -"cssstyle@npm:^2.3.0": - version: 2.3.0 - resolution: "cssstyle@npm:2.3.0" +"cssstyle@npm:^4.2.1": + version: 4.6.0 + resolution: "cssstyle@npm:4.6.0" dependencies: - cssom: "npm:~0.3.6" - checksum: 10/46f7f05a153446c4018b0454ee1464b50f606cb1803c90d203524834b7438eb52f3b173ba0891c618f380ced34ee12020675dc0052a7f1be755fe4ebc27ee977 + "@asamuzakjp/css-color": "npm:^3.2.0" + rrweb-cssom: "npm:^0.8.0" + checksum: 10/1cb25c9d66b87adb165f978b75cdeb6f225d7e31ba30a8934666046a0be037e4e7200d359bfa79d4f1a4aef1083ea09633b81bcdb36a2f2ac888e8c73ea3a289 languageName: node linkType: hard @@ -14465,14 +14960,13 @@ __metadata: languageName: node linkType: hard -"data-urls@npm:^3.0.2": - version: 3.0.2 - resolution: "data-urls@npm:3.0.2" +"data-urls@npm:^5.0.0": + version: 5.0.0 + resolution: "data-urls@npm:5.0.0" dependencies: - abab: "npm:^2.0.6" - whatwg-mimetype: "npm:^3.0.0" - whatwg-url: "npm:^11.0.0" - checksum: 10/033fc3dd0fba6d24bc9a024ddcf9923691dd24f90a3d26f6545d6a2f71ec6956f93462f2cdf2183cc46f10dc01ed3bcb36731a8208456eb1a08147e571fe2a76 + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.0.0" + checksum: 10/5c40568c31b02641a70204ff233bc4e42d33717485d074244a98661e5f2a1e80e38fe05a5755dfaf2ee549f2ab509d6a3af2a85f4b2ad2c984e5d176695eaf46 languageName: node linkType: hard @@ -14527,7 +15021,7 @@ __metadata: languageName: node linkType: hard -"decimal.js@npm:^10.4.2, decimal.js@npm:^10.6.0": +"decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69 @@ -14553,6 +15047,18 @@ __metadata: linkType: hard "dedent@npm:^1.0.0": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10/30b9062290dca72b0f5a6cd3667633448cef8cd0dec602eab61015741269ad49df90cabf0521f9a32d134ceab4e21aa7f097258c55cc3baadef94874686d6480 + languageName: node + linkType: hard + +"dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" peerDependencies: @@ -14708,7 +15214,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.0.0": +"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -14823,15 +15329,6 @@ __metadata: languageName: node linkType: hard -"domexception@npm:^4.0.0": - version: 4.0.0 - resolution: "domexception@npm:4.0.0" - dependencies: - webidl-conversions: "npm:^7.0.0" - checksum: 10/4ed443227d2871d76c58d852b2e93c68e0443815b2741348f20881bedee8c1ad4f9bfc5d30c7dec433cd026b57da63407c010260b1682fef4c8847e7181ea43f - languageName: node - linkType: hard - "domhandler@npm:^4.0.0, domhandler@npm:^4.2.0, domhandler@npm:^4.3.1": version: 4.3.1 resolution: "domhandler@npm:4.3.1" @@ -15302,24 +15799,6 @@ __metadata: languageName: node linkType: hard -"escodegen@npm:^2.0.0": - version: 2.1.0 - resolution: "escodegen@npm:2.1.0" - dependencies: - esprima: "npm:^4.0.1" - estraverse: "npm:^5.2.0" - esutils: "npm:^2.0.2" - source-map: "npm:~0.6.1" - dependenciesMeta: - source-map: - optional: true - bin: - escodegen: bin/escodegen.js - esgenerate: bin/esgenerate.js - checksum: 10/47719a65b2888b4586e3fa93769068b275961c13089e90d5d01a96a6e8e95871b1c3893576814c8fbf08a4a31a496f37e7b2c937cf231270f4d81de012832c7c - languageName: node - linkType: hard - "eslint-compat-utils@npm:^0.5.1": version: 0.5.1 resolution: "eslint-compat-utils@npm:0.5.1" @@ -15947,6 +16426,13 @@ __metadata: languageName: node linkType: hard +"exit-x@npm:^0.2.2": + version: 0.2.2 + resolution: "exit-x@npm:0.2.2" + checksum: 10/ee043053e6c1e237adf5ad9c4faf9f085b606f64a4ff859e2b138fab63fe642711d00c9af452a9134c4c92c55f752e818bfabab78c24d345022db163f3137027 + languageName: node + linkType: hard + "exit@npm:^0.1.2": version: 0.1.2 resolution: "exit@npm:0.1.2" @@ -15961,6 +16447,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:30.4.1, expect@npm:^30.0.0": + version: 30.4.1 + resolution: "expect@npm:30.4.1" + dependencies: + "@jest/expect-utils": "npm:30.4.1" + "@jest/get-type": "npm:30.1.0" + jest-matcher-utils: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + checksum: 10/f25051e5073c55369199ec3108ac01c60074bd09dad0b5a6c9fe40596438051cb52607e0e97505126422535b8d0dacab13aa29bb14e9fac71630bc710201a3f1 + languageName: node + linkType: hard + "expect@npm:^29.0.0, expect@npm:^29.7.0": version: 29.7.0 resolution: "expect@npm:29.7.0" @@ -16214,7 +16714,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.0": +"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -16563,7 +17063,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -16573,7 +17073,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -16733,9 +17233,9 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7": - version: 10.4.5 - resolution: "glob@npm:10.4.5" +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.3.7, glob@npm:^10.5.0": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" @@ -16745,7 +17245,7 @@ __metadata: path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10/698dfe11828b7efd0514cd11e573eaed26b2dff611f0400907281ce3eab0c1e56143ef9b35adc7c77ecc71fba74717b510c7c223d34ca8a98ec81777b293d4ac + checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653 languageName: node linkType: hard @@ -16892,9 +17392,9 @@ __metadata: languageName: node linkType: hard -"handlebars@npm:^4.7.8": - version: 4.7.8 - resolution: "handlebars@npm:4.7.8" +"handlebars@npm:^4.7.9": + version: 4.7.9 + resolution: "handlebars@npm:4.7.9" dependencies: minimist: "npm:^1.2.5" neo-async: "npm:^2.6.2" @@ -16906,7 +17406,7 @@ __metadata: optional: true bin: handlebars: bin/handlebars - checksum: 10/bd528f4dd150adf67f3f857118ef0fa43ff79a153b1d943fa0a770f2599e38b25a7a0dbac1a3611a4ec86970fd2325a81310fb788b5c892308c9f8743bd02e11 + checksum: 10/e755433d652e8a15fc02f83d7478e652359e7a4d354c4328818853ed4f8a39d4a09e1d22dad3c7213c5240864a65b3c840970b8b181745575dd957dd258f2b8d languageName: node linkType: hard @@ -17164,12 +17664,12 @@ __metadata: languageName: node linkType: hard -"html-encoding-sniffer@npm:^3.0.0": - version: 3.0.0 - resolution: "html-encoding-sniffer@npm:3.0.0" +"html-encoding-sniffer@npm:^4.0.0": + version: 4.0.0 + resolution: "html-encoding-sniffer@npm:4.0.0" dependencies: - whatwg-encoding: "npm:^2.0.0" - checksum: 10/707a812ec2acaf8bb5614c8618dc81e2fb6b4399d03e95ff18b65679989a072f4e919b9bef472039301a1bbfba64063ba4c79ea6e851c653ac9db80dbefe8fe5 + whatwg-encoding: "npm:^3.1.1" + checksum: 10/e86efd493293a5671b8239bd099d42128433bb3c7b0fdc7819282ef8e118a21f5dead0ad6f358e024a4e5c84f17ebb7a9b36075220fac0a6222b207248bede6f languageName: node linkType: hard @@ -17332,18 +17832,7 @@ __metadata: languageName: node linkType: hard -"http-proxy-agent@npm:^5.0.0": - version: 5.0.0 - resolution: "http-proxy-agent@npm:5.0.0" - dependencies: - "@tootallnate/once": "npm:2" - agent-base: "npm:6" - debug: "npm:4" - checksum: 10/5ee19423bc3e0fd5f23ce991b0755699ad2a46a440ce9cec99e8126bb98448ad3479d2c0ea54be5519db5b19a4ffaa69616bac01540db18506dd4dac3dc418f0 - languageName: node - linkType: hard - -"http-proxy-agent@npm:^7.0.0": +"http-proxy-agent@npm:^7.0.0, http-proxy-agent@npm:^7.0.2": version: 7.0.2 resolution: "http-proxy-agent@npm:7.0.2" dependencies: @@ -17392,23 +17881,13 @@ __metadata: languageName: node linkType: hard -"https-proxy-agent@npm:^5.0.1": - version: 5.0.1 - resolution: "https-proxy-agent@npm:5.0.1" - dependencies: - agent-base: "npm:6" - debug: "npm:4" - checksum: 10/f0dce7bdcac5e8eaa0be3c7368bb8836ed010fb5b6349ffb412b172a203efe8f807d9a6681319105ea1b6901e1972c7b5ea899672a7b9aad58309f766dcbe0df - languageName: node - linkType: hard - -"https-proxy-agent@npm:^7.0.1": - version: 7.0.5 - resolution: "https-proxy-agent@npm:7.0.5" +"https-proxy-agent@npm:^7.0.1, https-proxy-agent@npm:^7.0.6": + version: 7.0.6 + resolution: "https-proxy-agent@npm:7.0.6" dependencies: - agent-base: "npm:^7.0.2" + agent-base: "npm:^7.1.2" debug: "npm:4" - checksum: 10/6679d46159ab3f9a5509ee80c3a3fc83fba3a920a5e18d32176c3327852c3c00ad640c0c4210a8fd70ea3c4a6d3a1b375bf01942516e7df80e2646bdc77658ab + checksum: 10/784b628cbd55b25542a9d85033bdfd03d4eda630fb8b3c9477959367f3be95dc476ed2ecbb9836c359c7c698027fc7b45723a302324433590f45d6c1706e8c13 languageName: node linkType: hard @@ -17546,7 +18025,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.0.2": +"import-local@npm:^3.0.2, import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -17810,7 +18289,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.0.0": +"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -18074,7 +18553,7 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^6.0.0": +"istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" dependencies: @@ -18109,6 +18588,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-source-maps@npm:^5.0.0": + version: 5.0.6 + resolution: "istanbul-lib-source-maps@npm:5.0.6" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.23" + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + checksum: 10/569dd0a392ee3464b1fe1accbaef5cc26de3479eacb5b91d8c67ebb7b425d39fd02247d85649c3a0e9c29b600809fa60b5af5a281a75a89c01f385b1e24823a2 + languageName: node + linkType: hard + "istanbul-reports@npm:^3.1.3": version: 3.1.7 resolution: "istanbul-reports@npm:3.1.7" @@ -18145,6 +18635,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:30.4.1": + version: 30.4.1 + resolution: "jest-changed-files@npm:30.4.1" + dependencies: + execa: "npm:^5.1.1" + jest-util: "npm:30.4.1" + p-limit: "npm:^3.1.0" + checksum: 10/e566af0d6c53115edf34fd1d9bda3b857fcc6626bd032516a480b60ec208d515558eda1e693e76ef992633d71828a4c38a3e91a236142459855483cbd03ff4c4 + languageName: node + linkType: hard + "jest-changed-files@npm:^29.7.0": version: 29.7.0 resolution: "jest-changed-files@npm:29.7.0" @@ -18156,6 +18657,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:30.4.2": + version: 30.4.2 + resolution: "jest-circus@npm:30.4.2" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/expect": "npm:30.4.1" + "@jest/test-result": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + co: "npm:^4.6.0" + dedent: "npm:^1.6.0" + is-generator-fn: "npm:^2.1.0" + jest-each: "npm:30.4.1" + jest-matcher-utils: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-runtime: "npm:30.4.2" + jest-snapshot: "npm:30.4.1" + jest-util: "npm:30.4.1" + p-limit: "npm:^3.1.0" + pretty-format: "npm:30.4.1" + pure-rand: "npm:^7.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/b210db2cd3ab595c6053deee4c11e7eb5ea293b9af16fc87021288125dd42e8d13fdc77be21eca71f1636c6fe104edc26e32d6e707168763e97b0d1718c11203 + languageName: node + linkType: hard + "jest-circus@npm:^29.7.0": version: 29.7.0 resolution: "jest-circus@npm:29.7.0" @@ -18184,6 +18713,31 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:30.4.2": + version: 30.4.2 + resolution: "jest-cli@npm:30.4.2" + dependencies: + "@jest/core": "npm:30.4.2" + "@jest/test-result": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + chalk: "npm:^4.1.2" + exit-x: "npm:^0.2.2" + import-local: "npm:^3.2.0" + jest-config: "npm:30.4.2" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + yargs: "npm:^17.7.2" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: ./bin/jest.js + checksum: 10/dd33f8ee6500298639d5ec4d5f641306d9876fa182042ee40e9c63c59bdf9a82dc46da5bf38fae0783f6ac874df7f7f099006b263022954cf494f8468dfe583c + languageName: node + linkType: hard + "jest-cli@npm:^29.7.0": version: 29.7.0 resolution: "jest-cli@npm:29.7.0" @@ -18210,6 +18764,48 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:30.4.2": + version: 30.4.2 + resolution: "jest-config@npm:30.4.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/get-type": "npm:30.1.0" + "@jest/pattern": "npm:30.4.0" + "@jest/test-sequencer": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + babel-jest: "npm:30.4.1" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + deepmerge: "npm:^4.3.1" + glob: "npm:^10.5.0" + graceful-fs: "npm:^4.2.11" + jest-circus: "npm:30.4.2" + jest-docblock: "npm:30.4.0" + jest-environment-node: "npm:30.4.1" + jest-regex-util: "npm:30.4.0" + jest-resolve: "npm:30.4.1" + jest-runner: "npm:30.4.2" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + parse-json: "npm:^5.2.0" + pretty-format: "npm:30.4.1" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + esbuild-register: ">=3.4.0" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + checksum: 10/d37d923a5b3e815b73bbefe09c2f7fb092d6933f5f4ec0555fd9cacf97c58e8adb73fc4780a99e6a49e798444b1bb0097c9d5232feb1b8ffa31af589f9c02d9a + languageName: node + linkType: hard + "jest-config@npm:^29.7.0": version: 29.7.0 resolution: "jest-config@npm:29.7.0" @@ -18248,6 +18844,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.4.1": + version: 30.4.1 + resolution: "jest-diff@npm:30.4.1" + dependencies: + "@jest/diff-sequences": "npm:30.4.0" + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.4.1" + checksum: 10/594212df96bf101170afdb7eebd188d6d7d27241cbdd18b61d95f1142a3c94ae3b270377d15e719fb3c5efe4458d32acba8ad13dd6230dd7d6917a9eebb32625 + languageName: node + linkType: hard + "jest-diff@npm:^29.7.0": version: 29.7.0 resolution: "jest-diff@npm:29.7.0" @@ -18260,6 +18868,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:30.4.0": + version: 30.4.0 + resolution: "jest-docblock@npm:30.4.0" + dependencies: + detect-newline: "npm:^3.1.0" + checksum: 10/0ee25351ef941e832e53d10e74f34b38941b8f5fe750584661f6c5a115771818b081b8e39830c49d152fd361af81c7800c2d3a3c3a0e2dc742f7bfdbb6b1baaa + languageName: node + linkType: hard + "jest-docblock@npm:^29.7.0": version: 29.7.0 resolution: "jest-docblock@npm:29.7.0" @@ -18269,6 +18886,19 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:30.4.1": + version: 30.4.1 + resolution: "jest-each@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + "@jest/types": "npm:30.4.1" + chalk: "npm:^4.1.2" + jest-util: "npm:30.4.1" + pretty-format: "npm:30.4.1" + checksum: 10/077365c3fd0dc0d74aaa180fe4e3728533685413db58e7a30c8502d19a60a0b08259825d8aa36c569c8175a9d0cf901dd69c0a4eb63567c22c07bd0b566ddf89 + languageName: node + linkType: hard + "jest-each@npm:^29.7.0": version: 29.7.0 resolution: "jest-each@npm:29.7.0" @@ -18281,25 +18911,35 @@ __metadata: checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda languageName: node linkType: hard - -"jest-environment-jsdom@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-jsdom@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/jsdom": "npm:^20.0.0" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jsdom: "npm:^20.0.0" + +"jest-environment-jsdom@npm:^30.4.1": + version: 30.4.1 + resolution: "jest-environment-jsdom@npm:30.4.1" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/environment-jsdom-abstract": "npm:30.4.1" + jsdom: "npm:^26.1.0" peerDependencies: - canvas: ^2.5.0 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/23bbfc9bca914baef4b654f7983175a4d49b0f515a5094ebcb8f819f28ec186f53c0ba06af1855eac04bab1457f4ea79dae05f70052cf899863e8096daa6e0f5 + checksum: 10/2ed0860e257d404622306823f18c15f0fe881fc1d16e1245434b54eb690fa0784cf54cd7eef74b2e9373e1699b1726a0fb69d60ae388cc78eb36968fe3fd0bf6 + languageName: node + linkType: hard + +"jest-environment-node@npm:30.4.1, jest-environment-node@npm:^30.4.1": + version: 30.4.1 + resolution: "jest-environment-node@npm:30.4.1" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/fake-timers": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-mock: "npm:30.4.1" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + checksum: 10/b93157061c6fa4b62808741bdda5fbc0372a9d2a3db844f0ffb819ed104b3b5c6ff73375d9f57c0d8485a0ad6aed07d4cfbb98aca985c38e1a29f64096b940bc languageName: node linkType: hard @@ -18324,6 +18964,28 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:30.4.1": + version: 30.4.1 + resolution: "jest-haste-map@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fsevents: "npm:^2.3.3" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.4.0" + jest-util: "npm:30.4.1" + jest-worker: "npm:30.4.1" + picomatch: "npm:^4.0.3" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10/d26404c7258d03fa423604191bca39707438ca1e62a9a471c92fcd468fa386cbdce2c50b3834fb830b25836e3eee34e3070d22b016b42f0ab626c157f5726eeb + languageName: node + linkType: hard + "jest-haste-map@npm:^29.7.0": version: 29.7.0 resolution: "jest-haste-map@npm:29.7.0" @@ -18347,6 +19009,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:30.4.1": + version: 30.4.1 + resolution: "jest-leak-detector@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + pretty-format: "npm:30.4.1" + checksum: 10/8c0945d1c73f6a2abde8660f7fc5693b344cd1f5fd66153c0c2d13007f8429486eb99ecf15ac68d3d4410534fd0fad1ed430c32a641cdac66e021dbd33204c9a + languageName: node + linkType: hard + "jest-leak-detector@npm:^29.7.0": version: 29.7.0 resolution: "jest-leak-detector@npm:29.7.0" @@ -18357,6 +19029,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:30.4.1": + version: 30.4.1 + resolution: "jest-matcher-utils@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.4.1" + pretty-format: "npm:30.4.1" + checksum: 10/4da6e5c7fe5903fae7394233ea4b892567fb027065670c03096d01be0b389f858055c5ade20d59e82fedec6f3287e6f1720de526cd9a9ad3495432320adb9194 + languageName: node + linkType: hard + "jest-matcher-utils@npm:^29.7.0": version: 29.7.0 resolution: "jest-matcher-utils@npm:29.7.0" @@ -18369,6 +19053,24 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-message-util@npm:30.4.1" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.4.1" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-util: "npm:30.4.1" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.4.1" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/f83894efa37aa9c61c0a559b1027ecdb0d0cd8afd3e8ea74e797c707d58daea814e72f04b6db0bb6a148c12ae203e9c6e6c5544832ca5fae286c4f80c18ddc3f + languageName: node + linkType: hard + "jest-message-util@npm:^29.7.0": version: 29.7.0 resolution: "jest-message-util@npm:29.7.0" @@ -18386,6 +19088,17 @@ __metadata: languageName: node linkType: hard +"jest-mock@npm:30.4.1": + version: 30.4.1 + resolution: "jest-mock@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + jest-util: "npm:30.4.1" + checksum: 10/8d0c2794130217b9030b888ce380fe57d82388eec19351bd666440ba46f1e24a7e2bdf42cbe9bcfda2b881d4c0ea09db3c80131b9ab788fb5224af2a1339b422 + languageName: node + linkType: hard + "jest-mock@npm:^29.7.0": version: 29.7.0 resolution: "jest-mock@npm:29.7.0" @@ -18397,7 +19110,7 @@ __metadata: languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.2": +"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -18409,6 +19122,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.4.0": + version: 30.4.0 + resolution: "jest-regex-util@npm:30.4.0" + checksum: 10/8664fcc1d07c8236a3bd012c0f06ae9d14d96e758b32ee340a3a7c4c326d0b5052d8c4ae4f4c4184f08bf78723d905352f22923647df9658ace3604f03bf074f + languageName: node + linkType: hard + "jest-regex-util@npm:^29.6.3": version: 29.6.3 resolution: "jest-regex-util@npm:29.6.3" @@ -18416,6 +19136,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:30.4.2": + version: 30.4.2 + resolution: "jest-resolve-dependencies@npm:30.4.2" + dependencies: + jest-regex-util: "npm:30.4.0" + jest-snapshot: "npm:30.4.1" + checksum: 10/ec7e27d1abf67dfe9ec1a2887b5fa883e1e6ca38eb45506a82f93e29d8df62c18cb40ec07af43fe0fa65d568051a878b6649745f6c032d8962a8dad6323763ad + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:^29.7.0": version: 29.7.0 resolution: "jest-resolve-dependencies@npm:29.7.0" @@ -18426,6 +19156,22 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:30.4.1": + version: 30.4.1 + resolution: "jest-resolve@npm:30.4.1" + dependencies: + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.4.1" + jest-pnp-resolver: "npm:^1.2.3" + jest-util: "npm:30.4.1" + jest-validate: "npm:30.4.1" + slash: "npm:^3.0.0" + unrs-resolver: "npm:^1.7.11" + checksum: 10/197ca741df92c1006c2367142b5e44827d995f062e94a923f574e87ce04f966634851bb31f54ea377ca163a8362613947cd2311abf8a5712fe879b1ac15f662f + languageName: node + linkType: hard + "jest-resolve@npm:^29.7.0": version: 29.7.0 resolution: "jest-resolve@npm:29.7.0" @@ -18443,6 +19189,36 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:30.4.2": + version: 30.4.2 + resolution: "jest-runner@npm:30.4.2" + dependencies: + "@jest/console": "npm:30.4.1" + "@jest/environment": "npm:30.4.1" + "@jest/test-result": "npm:30.4.1" + "@jest/transform": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-docblock: "npm:30.4.0" + jest-environment-node: "npm:30.4.1" + jest-haste-map: "npm:30.4.1" + jest-leak-detector: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-resolve: "npm:30.4.1" + jest-runtime: "npm:30.4.2" + jest-util: "npm:30.4.1" + jest-watcher: "npm:30.4.1" + jest-worker: "npm:30.4.1" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/3ed8ee70019f1f5a63faaf07a15e522ec878601dc6eccbde7eb4d0529e4d1a7314e7041160075458257e3103f41d6e9bbb2df8698806805ae2768a7e90228103 + languageName: node + linkType: hard + "jest-runner@npm:^29.7.0": version: 29.7.0 resolution: "jest-runner@npm:29.7.0" @@ -18472,6 +19248,36 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:30.4.2": + version: 30.4.2 + resolution: "jest-runtime@npm:30.4.2" + dependencies: + "@jest/environment": "npm:30.4.1" + "@jest/fake-timers": "npm:30.4.1" + "@jest/globals": "npm:30.4.1" + "@jest/source-map": "npm:30.0.1" + "@jest/test-result": "npm:30.4.1" + "@jest/transform": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + cjs-module-lexer: "npm:^2.1.0" + collect-v8-coverage: "npm:^1.0.2" + glob: "npm:^10.5.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-mock: "npm:30.4.1" + jest-regex-util: "npm:30.4.0" + jest-resolve: "npm:30.4.1" + jest-snapshot: "npm:30.4.1" + jest-util: "npm:30.4.1" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/bc2612a17650e7187d2c778aa66fc07926f828eeb3a445e47ffa757f65a4fb29628a43c6e792196b0a3a06d099b0405b6654a2c314fc5092013690d01483fd75 + languageName: node + linkType: hard + "jest-runtime@npm:^29.7.0": version: 29.7.0 resolution: "jest-runtime@npm:29.7.0" @@ -18502,13 +19308,42 @@ __metadata: languageName: node linkType: hard -"jest-silent-reporter@npm:^0.5.0": - version: 0.5.0 - resolution: "jest-silent-reporter@npm:0.5.0" +"jest-silent-reporter@npm:^0.6.0": + version: 0.6.0 + resolution: "jest-silent-reporter@npm:0.6.0" dependencies: chalk: "npm:^4.0.0" jest-util: "npm:^26.0.0" - checksum: 10/54853eb1fd342eb074ff97bbd2f177c3425e635deb008ce1a5feede398930b6472549797342e3ea536f8e2e538d8718d6f281f3d44dec2ae4d32f61da0eb547e + checksum: 10/443e0abaf5a6dc8c17da1e8495b7a55f813224adc39b6d1954bf49ff7fe70533b1020571453180dbb8388ace87f8e1dfc79610a4554bb93334f6c4154231c292 + languageName: node + linkType: hard + +"jest-snapshot@npm:30.4.1": + version: 30.4.1 + resolution: "jest-snapshot@npm:30.4.1" + dependencies: + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/types": "npm:^7.27.3" + "@jest/expect-utils": "npm:30.4.1" + "@jest/get-type": "npm:30.1.0" + "@jest/snapshot-utils": "npm:30.4.1" + "@jest/transform": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + babel-preset-current-node-syntax: "npm:^1.2.0" + chalk: "npm:^4.1.2" + expect: "npm:30.4.1" + graceful-fs: "npm:^4.2.11" + jest-diff: "npm:30.4.1" + jest-matcher-utils: "npm:30.4.1" + jest-message-util: "npm:30.4.1" + jest-util: "npm:30.4.1" + pretty-format: "npm:30.4.1" + semver: "npm:^7.7.2" + synckit: "npm:^0.11.8" + checksum: 10/6135108d3e0e9fb93ed10fd9ad91d8dbe56f90a9ea84c32a0b551518f8c71f363299dcc301717f3ed82cfe2a276d7993d2b3ccfabea3e8020d49ae8b0f9b6cd8 languageName: node linkType: hard @@ -18540,6 +19375,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.4.1": + version: 30.4.1 + resolution: "jest-util@npm:30.4.1" + dependencies: + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10/603093e12076906afcf28be514d5b7ac4e3c0e26997b0047614cf2a308b65d773137304a1fb011d747517e881aeed067f6606b9937f5b838d67f6e5734b49ebe + languageName: node + linkType: hard + "jest-util@npm:^26.0.0": version: 26.6.2 resolution: "jest-util@npm:26.6.2" @@ -18568,6 +19417,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:30.4.1": + version: 30.4.1 + resolution: "jest-validate@npm:30.4.1" + dependencies: + "@jest/get-type": "npm:30.1.0" + "@jest/types": "npm:30.4.1" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" + leven: "npm:^3.1.0" + pretty-format: "npm:30.4.1" + checksum: 10/527fe8ad02df9a4f7f467ecc4e3ac2a37a27b7b30345e7bc3cb9c2ead33fbc8ed1290c0827baa06471281012c38abb96cb268af274a0a2350548e50db20a434f + languageName: node + linkType: hard + "jest-validate@npm:^29.7.0": version: 29.7.0 resolution: "jest-validate@npm:29.7.0" @@ -18582,6 +19445,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:30.4.1": + version: 30.4.1 + resolution: "jest-watcher@npm:30.4.1" + dependencies: + "@jest/test-result": "npm:30.4.1" + "@jest/types": "npm:30.4.1" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + jest-util: "npm:30.4.1" + string-length: "npm:^4.0.2" + checksum: 10/05350ed3d5643e87e22cc5faee14f912dfc06ba63d56944006d9837f2070ed509a1d124c7e7be3e3a9a6a382bd31d491146da6fda4483acd4b8292091888e9bd + languageName: node + linkType: hard + "jest-watcher@npm:^29.7.0": version: 29.7.0 resolution: "jest-watcher@npm:29.7.0" @@ -18598,12 +19477,25 @@ __metadata: languageName: node linkType: hard -"jest-when@npm:^3.4.2": - version: 3.6.0 - resolution: "jest-when@npm:3.6.0" +"jest-when@npm:^3.7.0": + version: 3.7.0 + resolution: "jest-when@npm:3.7.0" peerDependencies: jest: ">= 25" - checksum: 10/0cb92738ccfa5711a685107f4437f18aefbe3cda120c912a9d49b612eeef03a910481ab40fe753fd42c4e617ffbb3d84c6bd66a76d963dac7f1ad9e9e5059359 + checksum: 10/b5b88d077ed467aab220c71c885dbc5f448604f06e68f761ce9f479c99bb74e0dbf553d1cc980751d88401b034a578e1c44eec5e4095743b7586f02bb7c6313d + languageName: node + linkType: hard + +"jest-worker@npm:30.4.1": + version: 30.4.1 + resolution: "jest-worker@npm:30.4.1" + dependencies: + "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.4.1" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.1.1" + checksum: 10/ff6af73c9097fc07e90490d3e1e354c702390ef66f7f40054a15dd6d56809a25634179969ff80bde782a6c645f49fa48bf3aacfe7d05af7315c48020f9b2b1cd languageName: node linkType: hard @@ -18649,6 +19541,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^30.4.2": + version: 30.4.2 + resolution: "jest@npm:30.4.2" + dependencies: + "@jest/core": "npm:30.4.2" + "@jest/types": "npm:30.4.1" + import-local: "npm:^3.2.0" + jest-cli: "npm:30.4.2" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: ./bin/jest.js + checksum: 10/3690f4009a46781b480fbd4c8c60dd910a3cf8af76626f29971003c28eb17dfa322e86f3203e3da168edcc9f7ba973f3b1fbf15ca12393cc5f86bc61f3289fef + languageName: node + linkType: hard + "jiti@npm:^1.20.0": version: 1.21.7 resolution: "jiti@npm:1.21.7" @@ -18752,42 +19663,36 @@ __metadata: languageName: node linkType: hard -"jsdom@npm:^20.0.0": - version: 20.0.3 - resolution: "jsdom@npm:20.0.3" +"jsdom@npm:^26.1.0": + version: 26.1.0 + resolution: "jsdom@npm:26.1.0" dependencies: - abab: "npm:^2.0.6" - acorn: "npm:^8.8.1" - acorn-globals: "npm:^7.0.0" - cssom: "npm:^0.5.0" - cssstyle: "npm:^2.3.0" - data-urls: "npm:^3.0.2" - decimal.js: "npm:^10.4.2" - domexception: "npm:^4.0.0" - escodegen: "npm:^2.0.0" - form-data: "npm:^4.0.0" - html-encoding-sniffer: "npm:^3.0.0" - http-proxy-agent: "npm:^5.0.0" - https-proxy-agent: "npm:^5.0.1" + cssstyle: "npm:^4.2.1" + data-urls: "npm:^5.0.0" + decimal.js: "npm:^10.5.0" + html-encoding-sniffer: "npm:^4.0.0" + http-proxy-agent: "npm:^7.0.2" + https-proxy-agent: "npm:^7.0.6" is-potential-custom-element-name: "npm:^1.0.1" - nwsapi: "npm:^2.2.2" - parse5: "npm:^7.1.1" + nwsapi: "npm:^2.2.16" + parse5: "npm:^7.2.1" + rrweb-cssom: "npm:^0.8.0" saxes: "npm:^6.0.0" symbol-tree: "npm:^3.2.4" - tough-cookie: "npm:^4.1.2" - w3c-xmlserializer: "npm:^4.0.0" + tough-cookie: "npm:^5.1.1" + w3c-xmlserializer: "npm:^5.0.0" webidl-conversions: "npm:^7.0.0" - whatwg-encoding: "npm:^2.0.0" - whatwg-mimetype: "npm:^3.0.0" - whatwg-url: "npm:^11.0.0" - ws: "npm:^8.11.0" - xml-name-validator: "npm:^4.0.0" + whatwg-encoding: "npm:^3.1.1" + whatwg-mimetype: "npm:^4.0.0" + whatwg-url: "npm:^14.1.1" + ws: "npm:^8.18.0" + xml-name-validator: "npm:^5.0.0" peerDependencies: - canvas: ^2.5.0 + canvas: ^3.0.0 peerDependenciesMeta: canvas: optional: true - checksum: 10/a4cdcff5b07eed87da90b146b82936321533b5efe8124492acf7160ebd5b9cf2b3c2435683592bf1cffb479615245756efb6c173effc1906f845a86ed22af985 + checksum: 10/39d78c4889cac20826393400dce1faed1666e9244fe0c8342a8f08c315375878e6be7fcfe339a33d6ff1a083bfe9e71b16d56ecf4d9a87db2da8c795925ea8c1 languageName: node linkType: hard @@ -19199,7 +20104,7 @@ __metadata: languageName: node linkType: hard -"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0": +"lru-cache@npm:^10.0.1, lru-cache@npm:^10.2.0, lru-cache@npm:^10.4.3": version: 10.4.3 resolution: "lru-cache@npm:10.4.3" checksum: 10/e6e90267360476720fa8e83cc168aa2bf0311f3f2eea20a6ba78b90a885ae72071d9db132f40fda4129c803e7dcec3a6b6a6fbb44ca90b081630b810b5d6a41a @@ -20529,6 +21434,15 @@ __metadata: languageName: node linkType: hard +"napi-postinstall@npm:^0.3.0": + version: 0.3.4 + resolution: "napi-postinstall@npm:0.3.4" + bin: + napi-postinstall: lib/cli.js + checksum: 10/5541381508f9e1051ff3518701c7130ebac779abb3a1ffe9391fcc3cab4cc0569b0ba0952357db3f6b12909c3bb508359a7a60261ffd795feebbdab967175832 + languageName: node + linkType: hard + "natural-compare@npm:^1.4.0": version: 1.4.0 resolution: "natural-compare@npm:1.4.0" @@ -20832,7 +21746,7 @@ __metadata: languageName: node linkType: hard -"nwsapi@npm:^2.2.2": +"nwsapi@npm:^2.2.16": version: 2.2.23 resolution: "nwsapi@npm:2.2.23" checksum: 10/aa4a570039c33d70b51436d1bb533f3e2c33c488ccbe9b09285c46a6cee5ef266fd60103461085c6954ba52460786a8138f042958328c7c1b4763898eb3dadfa @@ -21427,7 +22341,7 @@ __metadata: languageName: node linkType: hard -"parse5@npm:^7.0.0, parse5@npm:^7.1.1, parse5@npm:^7.3.0": +"parse5@npm:^7.0.0, parse5@npm:^7.2.1, parse5@npm:^7.3.0": version: 7.3.0 resolution: "parse5@npm:7.3.0" dependencies: @@ -21600,10 +22514,10 @@ __metadata: languageName: node linkType: hard -"picomatch@npm:^4.0.4": - version: 4.0.4 - resolution: "picomatch@npm:4.0.4" - checksum: 10/f6ef80a3590827ce20378ae110ac78209cc4f74d39236370f1780f957b7ee41c12acde0e4651b90f39983506fd2f5e449994716f516db2e9752924aff8de93ce +"picomatch@npm:^4.0.3, picomatch@npm:^4.0.4": + version: 4.0.5 + resolution: "picomatch@npm:4.0.5" + checksum: 10/8bad770af9dcdb7f94ad1a893adcbe08a97d75a18872f8fed37161d3124217471c402b3929f051532f795f4ff2e53dcebb1644df4c1a5f3a9c476fef3b9f8c51 languageName: node linkType: hard @@ -21614,10 +22528,10 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.4": - version: 4.0.6 - resolution: "pirates@npm:4.0.6" - checksum: 10/d02dda76f4fec1cbdf395c36c11cf26f76a644f9f9a1bfa84d3167d0d3154d5289aacc72677aa20d599bb4a6937a471de1b65c995e2aea2d8687cbcd7e43ea5f +"pirates@npm:^4.0.4, pirates@npm:^4.0.7": + version: 4.0.7 + resolution: "pirates@npm:4.0.7" + checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 languageName: node linkType: hard @@ -22514,15 +23428,6 @@ __metadata: languageName: node linkType: hard -"prettier-2@npm:prettier@^2.8.8": - version: 2.8.8 - resolution: "prettier@npm:2.8.8" - bin: - prettier: bin-prettier.js - checksum: 10/00cdb6ab0281f98306cd1847425c24cbaaa48a5ff03633945ab4c701901b8e96ad558eb0777364ffc312f437af9b5a07d0f45346266e8245beaf6247b9c62b24 - languageName: node - linkType: hard - "prettier-linter-helpers@npm:^1.0.0": version: 1.0.0 resolution: "prettier-linter-helpers@npm:1.0.0" @@ -22551,6 +23456,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.4.1, pretty-format@npm:^30.0.0": + version: 30.4.1 + resolution: "pretty-format@npm:30.4.1" + dependencies: + "@jest/schemas": "npm:30.4.1" + ansi-styles: "npm:^5.2.0" + react-is-18: "npm:react-is@^18.3.1" + react-is-19: "npm:react-is@^19.2.5" + checksum: 10/60311ef47a646eeaec0432efe66290cb6f0d2eccb123a28ad4ab6d7e53087bc62db91cfd54c3cc00c89d6875aefb2bf6264381b6c9411ce6bff3d6aa8280abad + languageName: node + linkType: hard + "pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": version: 29.7.0 resolution: "pretty-format@npm:29.7.0" @@ -22715,15 +23632,6 @@ __metadata: languageName: node linkType: hard -"psl@npm:^1.1.33": - version: 1.15.0 - resolution: "psl@npm:1.15.0" - dependencies: - punycode: "npm:^2.3.1" - checksum: 10/5e7467eb5196eb7900d156783d12907d445c0122f76c73203ce96b148a6ccf8c5450cc805887ffada38ff92d634afcf33720c24053cb01d5b6598d1c913c5caf - languageName: node - linkType: hard - "pump@npm:^3.0.0": version: 3.0.4 resolution: "pump@npm:3.0.4" @@ -22764,6 +23672,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^7.0.0": + version: 7.0.1 + resolution: "pure-rand@npm:7.0.1" + checksum: 10/c61a576fda5032ec9763ecb000da4a8f19263b9e2f9ae9aa2759c8fbd9dc6b192b2ce78391ebd41abb394a5fedb7bcc4b03c9e6141ac8ab20882dd5717698b80 + languageName: node + linkType: hard + "pvtsutils@npm:^1.3.6": version: 1.3.6 resolution: "pvtsutils@npm:1.3.6" @@ -22789,13 +23704,6 @@ __metadata: languageName: node linkType: hard -"querystringify@npm:^2.1.1": - version: 2.2.0 - resolution: "querystringify@npm:2.2.0" - checksum: 10/46ab16f252fd892fc29d6af60966d338cdfeea68a231e9457631ffd22d67cec1e00141e0a5236a2eb16c0d7d74175d9ec1d6f963660c6f2b1c2fc85b194c5680 - languageName: node - linkType: hard - "queue-microtask@npm:^1.2.2": version: 1.2.3 resolution: "queue-microtask@npm:1.2.3" @@ -22912,6 +23820,20 @@ __metadata: languageName: node linkType: hard +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + +"react-is-19@npm:react-is@^19.2.5": + version: 19.2.7 + resolution: "react-is@npm:19.2.7" + checksum: 10/ae0d3ae7638aa2fa2a82a78a817daf2806e57fa0aef357d07e1e8d1e9a301902e5967168e9334b5816db0df8fa980a725cd57569ba5a9e6e76a71e117b18be04 + languageName: node + linkType: hard + "react-is@npm:^16.13.1, react-is@npm:^16.6.0, react-is@npm:^16.7.0": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -22919,13 +23841,6 @@ __metadata: languageName: node linkType: hard -"react-is@npm:^18.0.0": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 - languageName: node - linkType: hard - "react-json-view-lite@npm:^2.3.0": version: 2.5.0 resolution: "react-json-view-lite@npm:2.5.0" @@ -23569,6 +24484,13 @@ __metadata: languageName: node linkType: hard +"rrweb-cssom@npm:^0.8.0": + version: 0.8.0 + resolution: "rrweb-cssom@npm:0.8.0" + checksum: 10/07521ee36fb6569c17906afad1ac7ff8f099d49ade9249e190693ac36cdf27f88d9acf0cc66978935d5d0a23fca105643d7e9125b9a9d91ed9db9e02d31d7d80 + languageName: node + linkType: hard + "rtlcss@npm:^4.1.0": version: 4.3.0 resolution: "rtlcss@npm:4.3.0" @@ -23760,12 +24682,12 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.3, semver@npm:^7.8.1": - version: 7.8.2 - resolution: "semver@npm:7.8.2" +"semver@npm:^7.1.1, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1": + version: 7.8.5 + resolution: "semver@npm:7.8.5" bin: semver: bin/semver.js - checksum: 10/52221d8f1cadacda3cc3f0a2e7f7146e0442c7f4219acb25970bed055f5d0a6afbba5f22e293b078c2e93fca3dce0a08b088485e8b75d32a165f16c3627091c8 + checksum: 10/9b01d2ff11e6e4a4539b7ca3c5f280c8704cb397a28504469f2ed4f00ad2194748d756647362a9712fff30984d15772ab7f083108c2fb508e2096ae9e708f22c languageName: node linkType: hard @@ -24226,7 +25148,7 @@ __metadata: languageName: node linkType: hard -"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0, source-map@npm:~0.6.1": +"source-map@npm:^0.6.0, source-map@npm:^0.6.1, source-map@npm:~0.6.0": version: 0.6.1 resolution: "source-map@npm:0.6.1" checksum: 10/59ef7462f1c29d502b3057e822cdbdae0b0e565302c4dd1a95e11e793d8d9d62006cdc10e0fd99163ca33ff2071360cf50ee13f90440806e7ed57d81cba2f7ff @@ -24355,7 +25277,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.3": +"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -24400,7 +25322,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.1": +"string-length@npm:^4.0.1, string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -24613,7 +25535,7 @@ __metadata: languageName: node linkType: hard -"supports-color@npm:^8, supports-color@npm:^8.0.0": +"supports-color@npm:^8, supports-color@npm:^8.0.0, supports-color@npm:^8.1.1": version: 8.1.1 resolution: "supports-color@npm:8.1.1" dependencies: @@ -24660,6 +25582,15 @@ __metadata: languageName: node linkType: hard +"synckit@npm:^0.11.8": + version: 0.11.12 + resolution: "synckit@npm:0.11.12" + dependencies: + "@pkgr/core": "npm:^0.2.9" + checksum: 10/2f51978bfed81aaf0b093f596709a72c49b17909020f42b43c5549f9c0fe18b1fe29f82e41ef771172d729b32e9ce82900a85d2b87fa14d59f886d4df8d7a329 + languageName: node + linkType: hard + "synckit@npm:^0.9.1": version: 0.9.2 resolution: "synckit@npm:0.9.2" @@ -24868,6 +25799,24 @@ __metadata: languageName: node linkType: hard +"tldts-core@npm:^6.1.86": + version: 6.1.86 + resolution: "tldts-core@npm:6.1.86" + checksum: 10/cb5dff9cc15661ac773a2099e98c99a5cb3cebc35909c23cc4261ff7992032c7501995ae995de3574dbbf3431e59c47496534d52f5e96abcb231f0e72144c020 + languageName: node + linkType: hard + +"tldts@npm:^6.1.32": + version: 6.1.86 + resolution: "tldts@npm:6.1.86" + dependencies: + tldts-core: "npm:^6.1.86" + bin: + tldts: bin/cli.js + checksum: 10/f7e66824e44479ccdda55ea556af14ce61c4d27708be403e3f90631defde49f82a580e1ca07187cc7e3b349e257a30c2808a22903f3a0548e136ebb609ccc109 + languageName: node + linkType: hard + "tmpl@npm:1.0.5": version: 1.0.5 resolution: "tmpl@npm:1.0.5" @@ -24898,24 +25847,21 @@ __metadata: languageName: node linkType: hard -"tough-cookie@npm:^4.1.2": - version: 4.1.4 - resolution: "tough-cookie@npm:4.1.4" +"tough-cookie@npm:^5.1.1": + version: 5.1.2 + resolution: "tough-cookie@npm:5.1.2" dependencies: - psl: "npm:^1.1.33" - punycode: "npm:^2.1.1" - universalify: "npm:^0.2.0" - url-parse: "npm:^1.5.3" - checksum: 10/75663f4e2cd085f16af0b217e4218772adf0617fb3227171102618a54ce0187a164e505d61f773ed7d65988f8ff8a8f935d381f87da981752c1171b076b4afac + tldts: "npm:^6.1.32" + checksum: 10/de430e6e6d34b794137e05b8ac2aa6b74ebbe6cdceb4126f168cf1e76101162a4b2e0e7587c3b70e728bd8654fc39958b2035be7619ee6f08e7257610ba4cd04 languageName: node linkType: hard -"tr46@npm:^3.0.0": - version: 3.0.0 - resolution: "tr46@npm:3.0.0" +"tr46@npm:^5.1.0": + version: 5.1.1 + resolution: "tr46@npm:5.1.1" dependencies: - punycode: "npm:^2.1.1" - checksum: 10/b09a15886cbfaee419a3469081223489051ce9dca3374dd9500d2378adedbee84a3c73f83bfdd6bb13d53657753fc0d4e20a46bfcd3f1b9057ef528426ad7ce4 + punycode: "npm:^2.3.1" + checksum: 10/833a0e1044574da5790148fd17866d4ddaea89e022de50279967bcd6b28b4ce0d30d59eb3acf9702b60918975b3bad481400337e3a2e6326cffa5c77b874753d languageName: node linkType: hard @@ -24958,17 +25904,17 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.2.5": - version: 29.4.6 - resolution: "ts-jest@npm:29.4.6" +"ts-jest@npm:^29.2.5, ts-jest@npm:^29.4.11": + version: 29.4.11 + resolution: "ts-jest@npm:29.4.11" dependencies: bs-logger: "npm:^0.2.6" fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.8" + handlebars: "npm:^4.7.9" json5: "npm:^2.2.3" lodash.memoize: "npm:^4.1.2" make-error: "npm:^1.3.6" - semver: "npm:^7.7.3" + semver: "npm:^7.8.0" type-fest: "npm:^4.41.0" yargs-parser: "npm:^21.1.1" peerDependencies: @@ -24978,7 +25924,7 @@ __metadata: babel-jest: ^29.0.0 || ^30.0.0 jest: ^29.0.0 || ^30.0.0 jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <6" + typescript: ">=4.3 <7" peerDependenciesMeta: "@babel/core": optional: true @@ -24994,7 +25940,7 @@ __metadata: optional: true bin: ts-jest: cli.js - checksum: 10/e0ff9e13f684166d5331808b288043b8054f49a1c2970480a92ba3caec8d0ff20edd092f2a4e7a3ad8fcb9ba4d674bee10ec7ee75046d8066bbe43a7d16cf72e + checksum: 10/8a76800cac1c102c789429a82655adde1d540d7ff025fc888006f4f458afe07ca4d3f73622a10a604edea06aa12c298605cd1a208a6380c0e3d591a3daed8af6 languageName: node linkType: hard @@ -25383,13 +26329,6 @@ __metadata: languageName: node linkType: hard -"universalify@npm:^0.2.0": - version: 0.2.0 - resolution: "universalify@npm:0.2.0" - checksum: 10/e86134cb12919d177c2353196a4cc09981524ee87abf621f7bc8d249dbbbebaec5e7d1314b96061497981350df786e4c5128dbf442eba104d6e765bc260678b5 - languageName: node - linkType: hard - "universalify@npm:^2.0.0": version: 2.0.1 resolution: "universalify@npm:2.0.1" @@ -25404,6 +26343,73 @@ __metadata: languageName: node linkType: hard +"unrs-resolver@npm:^1.7.11": + version: 1.11.1 + resolution: "unrs-resolver@npm:1.11.1" + dependencies: + "@unrs/resolver-binding-android-arm-eabi": "npm:1.11.1" + "@unrs/resolver-binding-android-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-arm64": "npm:1.11.1" + "@unrs/resolver-binding-darwin-x64": "npm:1.11.1" + "@unrs/resolver-binding-freebsd-x64": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-arm64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.11.1" + "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-gnu": "npm:1.11.1" + "@unrs/resolver-binding-linux-x64-musl": "npm:1.11.1" + "@unrs/resolver-binding-wasm32-wasi": "npm:1.11.1" + "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.11.1" + "@unrs/resolver-binding-win32-x64-msvc": "npm:1.11.1" + napi-postinstall: "npm:^0.3.0" + dependenciesMeta: + "@unrs/resolver-binding-android-arm-eabi": + optional: true + "@unrs/resolver-binding-android-arm64": + optional: true + "@unrs/resolver-binding-darwin-arm64": + optional: true + "@unrs/resolver-binding-darwin-x64": + optional: true + "@unrs/resolver-binding-freebsd-x64": + optional: true + "@unrs/resolver-binding-linux-arm-gnueabihf": + optional: true + "@unrs/resolver-binding-linux-arm-musleabihf": + optional: true + "@unrs/resolver-binding-linux-arm64-gnu": + optional: true + "@unrs/resolver-binding-linux-arm64-musl": + optional: true + "@unrs/resolver-binding-linux-ppc64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-musl": + optional: true + "@unrs/resolver-binding-linux-s390x-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-musl": + optional: true + "@unrs/resolver-binding-wasm32-wasi": + optional: true + "@unrs/resolver-binding-win32-arm64-msvc": + optional: true + "@unrs/resolver-binding-win32-ia32-msvc": + optional: true + "@unrs/resolver-binding-win32-x64-msvc": + optional: true + checksum: 10/4de653508cbaae47883a896bd5cdfef0e5e87b428d62620d16fd35cd534beaebf08ebf0cf2f8b4922aa947b2fe745180facf6cc3f39ba364f7ce0f974cb06a70 + languageName: node + linkType: hard + "unzipper@npm:^0.12.3": version: 0.12.3 resolution: "unzipper@npm:0.12.3" @@ -25479,25 +26485,6 @@ __metadata: languageName: node linkType: hard -"url-parse@npm:^1.5.3": - version: 1.5.10 - resolution: "url-parse@npm:1.5.10" - dependencies: - querystringify: "npm:^2.1.1" - requires-port: "npm:^1.0.0" - checksum: 10/c9e96bc8c5b34e9f05ddfeffc12f6aadecbb0d971b3cc26015b58d5b44676a99f50d5aeb1e5c9e61fa4d49961ae3ab1ae997369ed44da51b2f5ac010d188e6ad - languageName: node - linkType: hard - -"use-sync-external-store@npm:^1.6.0": - version: 1.6.0 - resolution: "use-sync-external-store@npm:1.6.0" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10/b40ad2847ba220695bff2d4ba4f4d60391c0fb4fb012faa7a4c18eb38b69181936f5edc55a522c4d20a788d1a879b73c3810952c9d0fd128d01cb3f22042c09e - languageName: node - linkType: hard - "utf8@npm:^3.0.0": version: 3.0.0 resolution: "utf8@npm:3.0.0" @@ -25677,12 +26664,12 @@ __metadata: languageName: node linkType: hard -"w3c-xmlserializer@npm:^4.0.0": - version: 4.0.0 - resolution: "w3c-xmlserializer@npm:4.0.0" +"w3c-xmlserializer@npm:^5.0.0": + version: 5.0.0 + resolution: "w3c-xmlserializer@npm:5.0.0" dependencies: - xml-name-validator: "npm:^4.0.0" - checksum: 10/9a00c412b5496f4f040842c9520bc0aaec6e0c015d06412a91a723cd7d84ea605ab903965f546b4ecdb3eae267f5145ba08565222b1d6cb443ee488cda9a0aee + xml-name-validator: "npm:^5.0.0" + checksum: 10/d78f59e6b4f924aa53b6dfc56949959229cae7fe05ea9374eb38d11edcec01398b7f5d7a12576bd5acc57ff446abb5c9115cd83b9d882555015437cf858d42f0 languageName: node linkType: hard @@ -25954,15 +26941,6 @@ __metadata: languageName: node linkType: hard -"whatwg-encoding@npm:^2.0.0": - version: 2.0.0 - resolution: "whatwg-encoding@npm:2.0.0" - dependencies: - iconv-lite: "npm:0.6.3" - checksum: 10/162d712d88fd134a4fe587e53302da812eb4215a1baa4c394dfd86eff31d0a079ff932c05233857997de07481093358d6e7587997358f49b8a580a777be22089 - languageName: node - linkType: hard - "whatwg-encoding@npm:^3.1.1": version: 3.1.1 resolution: "whatwg-encoding@npm:3.1.1" @@ -25979,13 +26957,6 @@ __metadata: languageName: node linkType: hard -"whatwg-mimetype@npm:^3.0.0": - version: 3.0.0 - resolution: "whatwg-mimetype@npm:3.0.0" - checksum: 10/96f9f628c663c2ae05412c185ca81b3df54bcb921ab52fe9ebc0081c1720f25d770665401eb2338ab7f48c71568133845638e18a81ed52ab5d4dcef7d22b40ef - languageName: node - linkType: hard - "whatwg-mimetype@npm:^4.0.0": version: 4.0.0 resolution: "whatwg-mimetype@npm:4.0.0" @@ -25993,13 +26964,13 @@ __metadata: languageName: node linkType: hard -"whatwg-url@npm:^11.0.0": - version: 11.0.0 - resolution: "whatwg-url@npm:11.0.0" +"whatwg-url@npm:^14.0.0, whatwg-url@npm:^14.1.1": + version: 14.2.0 + resolution: "whatwg-url@npm:14.2.0" dependencies: - tr46: "npm:^3.0.0" + tr46: "npm:^5.1.0" webidl-conversions: "npm:^7.0.0" - checksum: 10/dfcd51c6f4bfb54685528fb10927f3fd3d7c809b5671beef4a8cdd7b1408a7abf3343a35bc71dab83a1424f1c1e92cc2700d7930d95d231df0fac361de0c7648 + checksum: 10/f0a95b0601c64f417c471536a2d828b4c16fe37c13662483a32f02f183ed0f441616609b0663fb791e524e8cd56d9a86dd7366b1fc5356048ccb09b576495e7c languageName: node linkType: hard @@ -26172,7 +27143,7 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^5.0.0": +"write-file-atomic@npm:^5.0.0, write-file-atomic@npm:^5.0.1": version: 5.0.1 resolution: "write-file-atomic@npm:5.0.1" dependencies: @@ -26242,7 +27213,7 @@ __metadata: languageName: node linkType: hard -"ws@npm:^8.11.0, ws@npm:^8.18.0, ws@npm:^8.18.3": +"ws@npm:^8.18.0, ws@npm:^8.18.3": version: 8.21.0 resolution: "ws@npm:8.21.0" peerDependencies: @@ -26291,10 +27262,10 @@ __metadata: languageName: node linkType: hard -"xml-name-validator@npm:^4.0.0": - version: 4.0.0 - resolution: "xml-name-validator@npm:4.0.0" - checksum: 10/f9582a3f281f790344a471c207516e29e293c6041b2c20d84dd6e58832cd7c19796c47e108fd4fd4b164a5e72ad94f2268f8ace8231cde4a2c6428d6aa220f92 +"xml-name-validator@npm:^5.0.0": + version: 5.0.0 + resolution: "xml-name-validator@npm:5.0.0" + checksum: 10/43f30f3f6786e406dd665acf08cd742d5f8a46486bd72517edb04b27d1bcd1599664c2a4a99fc3f1e56a3194bff588b12f178b7972bc45c8047bdc4c3ac8d4a1 languageName: node linkType: hard @@ -26373,7 +27344,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -26406,6 +27377,21 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^17.3.1": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 3de671394bb36667d04d8d6428e53dafb106cdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 18:51:01 +0200 Subject: [PATCH 08/67] fix: issue with old accessToken and state update while controller has been reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 78 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 29 +++++++ 2 files changed, 107 insertions(+) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index e62d5a3dbe0..50328ab9430 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -257,6 +257,46 @@ describe('KycController', () => { ); }); + it('clears stale auth tokens when a new session is created', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + accessToken: 'stale-access', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ + sessionToken: 'new-session', + }); + + // Establish an auth-frame client token from a prior authentication. + const envelope = envelopeFor(controller, { clientToken: 'old-client' }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'connectionRequired', credentials: envelope }, + }, + }); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=old-client', + ); + + // Creating a new session must invalidate the carried-over auth. + await controller.acceptTermsAndStartSession(); + + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.sessionToken).toBe('new-session'); + }, + ); + }); + it('reverts to terms when session creation fails', async () => { await withController( { @@ -779,6 +819,44 @@ describe('KycController', () => { }, ); }); + + it('discards a successful result when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + // Simulate a reset() landing while the HTTP call is in flight. + controller.reset(); + return { kycRequired: true }; + }); + + const result = await controller.checkKycRequired({ product: 'ramps' }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.kycRequiredByProduct.ramps).toBeUndefined(); + expect(controller.state.lastCheckedAt).toBeNull(); + }, + ); + }); + + it('discards an error when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + controller.reset(); + throw new Error('down'); + }); + + const result = await controller.checkKycRequired({ product: 'ramps' }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); }); describe('getKycStatus', () => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 08696b5a089..9b26fa728e0 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -323,6 +323,13 @@ export class KycController extends BaseController< /** Auth-frame client token, kept out of state. */ #authClientToken: string | null = null; + /** + * Monotonic flow generation. Incremented by {@link reset} so in-flight async + * work (e.g. the KYC-required check) can detect that it was superseded and + * avoid writing stale results onto a reset controller. + */ + #generation = 0; + /** * Constructs a new {@link KycController}. * @@ -477,10 +484,18 @@ export class KycController extends BaseController< return; } + // A new session invalidates any authentication carried over from a prior + // session. Clear the stale access token and auth-frame client token so + // `buildAuthFrameUrl` cannot return a URL tied to an old client token and + // `checkKycRequired` cannot run with an access token from an earlier + // authentication. The Check/Auth frames re-populate these for the new + // session. + this.#authClientToken = null; this.update((state) => { state.error = null; state.phase = 'session'; state.statusMessage = 'Creating session...'; + state.accessToken = null; }); try { @@ -763,6 +778,10 @@ export class KycController extends BaseController< return false; } + // Capture the flow generation so we can detect a `reset()` that happens + // while the HTTP call is in flight and avoid writing stale results. + const generation = this.#generation; + this.update((state) => { state.phase = 'submit'; state.statusMessage = 'Checking KYC status...'; @@ -773,6 +792,11 @@ export class KycController extends BaseController< 'KycService:checkKycRequired', { accessToken, country, capabilities: [{ product: params.product }] }, ); + // The flow was reset while the check was in flight; discard the result + // rather than resurrecting a done/cached state on an idle controller. + if (this.#generation !== generation) { + return false; + } this.update((state) => { state.kycRequiredByProduct[params.product] = kycRequired; state.lastCheckedAt = new Date().toISOString(); @@ -781,6 +805,9 @@ export class KycController extends BaseController< }); return kycRequired; } catch (error) { + if (this.#generation !== generation) { + return false; + } this.#fail(`KYC check failed: ${String(error)}`); return false; } @@ -911,6 +938,8 @@ export class KycController extends BaseController< */ reset(): void { this.#authClientToken = null; + // Invalidate any in-flight async work started before this reset. + this.#generation += 1; this.update((state) => { state.phase = 'idle'; state.statusMessage = ''; From 49ec4f1d71765b0315c17c323e6b9de115e9b961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 22:12:20 +0200 Subject: [PATCH 09/67] fix: another race condition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 43 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 40 +++++++++++++++-- 2 files changed, 79 insertions(+), 4 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 50328ab9430..5c87b6fc75a 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -961,6 +961,49 @@ describe('KycController', () => { expect(controller.state.sumsub.status).toBe('failed'); }); }); + + it('aborts without launching the SDK when reset() runs while in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // Simulate a reset() landing while the UKYC session is being created. + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(launcher.launch).not.toHaveBeenCalled(); + // The interrupted step must not write stale sub-flow state. + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // First status arrives on the active flow, then a reset() lands and + // a later status + the resolved result must not resurrect state. + onStatusChange?.('idle', 'InProgress'); + controller.reset(); + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.result).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); }); describe('reset', () => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 9b26fa728e0..c388338a75a 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -847,6 +847,11 @@ export class KycController extends BaseController< throw new Error(error); } + // Capture the flow generation so each async step can detect a `reset()` + // that lands mid-flight and avoid writing stale sub-flow state (or, worse, + // presenting the SDK) on a controller that is now idle. + const generation = this.#generation; + try { this.update((state) => { state.sumsub.status = 'creatingSession'; @@ -872,7 +877,7 @@ export class KycController extends BaseController< jwtToken, }; - this.update((state) => { + this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'fetchingToken'; state.sumsub.sessionId = sessionId; }); @@ -882,6 +887,12 @@ export class KycController extends BaseController< exchange, ); + // A reset() landed while the session/token was being prepared; abort + // before presenting the SDK rather than launching on an idle controller. + if (this.#generation !== generation) { + return {}; + } + this.update((state) => { state.sumsub.status = 'launching'; state.sumsub.applicantAccessToken = applicantAccessToken; @@ -905,7 +916,7 @@ export class KycController extends BaseController< if (next === SUMSUB_COMPLETED_STATUS) { reachedCompletion = true; } - this.update((state) => { + this.#updateIfCurrent(generation, (state) => { state.sumsub.status = next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress'; }); @@ -917,14 +928,14 @@ export class KycController extends BaseController< // Only record `complete` when the SDK actually reported completion; // otherwise treat the resolved-but-unfinished flow as `failed` so // consumers and UI do not mistake it for a finished verification. - this.update((state) => { + this.#updateIfCurrent(generation, (state) => { state.sumsub.status = reachedCompletion ? 'complete' : 'failed'; state.sumsub.result = result as Json; }); return result; } catch (error) { const result = { error: String(error) }; - this.update((state) => { + this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'failed'; state.sumsub.result = result; }); @@ -959,6 +970,27 @@ export class KycController extends BaseController< }); } + /** + * Applies a state update only when the flow has not been reset since + * `generation` was captured. Prevents an in-flight async step from writing + * stale results onto a controller that a concurrent {@link reset} has + * returned to idle. + * + * @param generation - The flow generation captured before the async work. + * @param updater - The state mutation to apply when still current. + * @returns `true` if the update was applied, `false` if it was superseded. + */ + #updateIfCurrent( + generation: number, + updater: (state: KycControllerState) => void, + ): boolean { + if (this.#generation !== generation) { + return false; + } + this.update(updater); + return true; + } + /** * Transitions to the error phase with a message. * From 20cff98d95f94a316a81271375efd6590668940c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 23 Jul 2026 23:15:04 +0200 Subject: [PATCH 10/67] fix: add links in changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 22e08cf4f2a..29f5a436616 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,13 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients +- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. -- Add automatic post-authentication continuation to `KycController` +- Add automatic post-authentication continuation to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) - `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state. - When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior). -- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API +- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#9615](https://github.com/MetaMask/core/pull/9615)) [Unreleased]: https://github.com/MetaMask/core/ From abc47a6b6507299416c151608889ac15285f3f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 10:46:30 +0200 Subject: [PATCH 11/67] fix: lint issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/KycService.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 48117c65407..fec9a4baa2b 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -208,9 +208,8 @@ export class KycService { const location = await this.#messenger.call( 'GeolocationController:getGeolocation', ); - const alpha2 = String(location ?? '') - .split('-')[0] - .toUpperCase(); + assert(location, string()); + const alpha2 = location.split('-')[0].toUpperCase(); if (!alpha2 || alpha2 === 'UNKNOWN') { throw new Error( `Unable to determine country from geolocation (got "${location}").`, @@ -390,6 +389,7 @@ export class KycService { const bearerToken = await this.#messenger.call( 'AuthenticationController:getBearerToken', ); + assert(bearerToken, string()); if (!bearerToken) { throw new Error( 'Unable to obtain an authentication bearer token — is the wallet signed in?', From 461473d2aed46c1709506ec71e6bb83f99d94900 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 11:06:52 +0200 Subject: [PATCH 12/67] fix: dedupe dependencies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- yarn.lock | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/yarn.lock b/yarn.lock index a78434321db..f8b525f11af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14067,20 +14067,13 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.0.0": +"cjs-module-lexer@npm:^1.0.0, cjs-module-lexer@npm:^1.3.1": version: 1.4.3 resolution: "cjs-module-lexer@npm:1.4.3" checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 languageName: node linkType: hard -"cjs-module-lexer@npm:^1.3.1": - version: 1.4.0 - resolution: "cjs-module-lexer@npm:1.4.0" - checksum: 10/b041096749792526120d8b8756929f8ef5dd4596502a0e1013f857e3027acd6091915fea77037921d70ee1a99988a100d994d3d3c2e323b04dd4c5ffd516cf13 - languageName: node - linkType: hard - "cjs-module-lexer@npm:^2.1.0": version: 2.2.0 resolution: "cjs-module-lexer@npm:2.2.0" @@ -15046,7 +15039,7 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.0.0": +"dedent@npm:^1.0.0, dedent@npm:^1.6.0": version: 1.7.2 resolution: "dedent@npm:1.7.2" peerDependencies: @@ -15058,18 +15051,6 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0": - version: 1.7.1 - resolution: "dedent@npm:1.7.1" - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - checksum: 10/78785ef592e37e0b1ca7a7a5964c8f3dee1abdff46c5bb49864168579c122328f6bb55c769bc7e005046a7381c3372d3859f0f78ab083950fa146e1c24873f4f - languageName: node - linkType: hard - "deep-extend@npm:^0.6.0": version: 0.6.0 resolution: "deep-extend@npm:0.6.0" @@ -27344,7 +27325,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -27377,7 +27358,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1": +"yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": version: 17.7.3 resolution: "yargs@npm:17.7.3" dependencies: From 41275fc7b5cf6596c4f58d411e4bc1eabab37db4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 12:17:04 +0200 Subject: [PATCH 13/67] fix: cursor comments + state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 173 +++++++++++++++++- packages/kyc-controller/src/KycController.ts | 153 ++++++++++++---- packages/kyc-controller/src/KycService.ts | 18 +- 3 files changed, 299 insertions(+), 45 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 5c87b6fc75a..5e3e4797c14 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -275,7 +275,9 @@ describe('KycController', () => { }); // Establish an auth-frame client token from a prior authentication. - const envelope = envelopeFor(controller, { clientToken: 'old-client' }); + const envelope = envelopeFor(controller, { + clientToken: 'old-client', + }); await controller.handleFrameMessage({ message: { kind: 'complete', @@ -320,6 +322,31 @@ describe('KycController', () => { ); }); + it('clears the active product when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + activeProduct: 'card', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ product: 'ramps' }); + + // The failed flow must not leave a lingering product behind that a + // later product-less `acceptTermsAndStartSession` would auto-run. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + it('fails when no email is available', async () => { await withController( { @@ -687,6 +714,114 @@ describe('KycController', () => { ); }); + it('ignores a duplicate completion while a prior continuation is in flight', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + // Hold the KYC-required check open so the first continuation is still + // in flight when the second (duplicate) completion arrives. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValue( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const message = { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }; + + const first = controller.handleFrameMessage({ message }); + const second = controller.handleFrameMessage({ message }); + + releaseCheck({ kycRequired: true }); + await Promise.all([first, second]); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => { + await withController( + { + options: { + state: { + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers }) => { + // The keypair is stable across reset, so both envelopes can be built + // up front while the session token (used only to derive the public + // key here) is still present. + const envelope1 = envelopeFor(controller, { + accessToken: 'access-1', + }); + const envelope2 = envelopeFor(controller, { + accessToken: 'access-2', + }); + const messageFor = (credentials: unknown) => ({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials }, + }); + + // Hold the first continuation open so a reset can land while it is + // still in flight. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValueOnce( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + + const first = controller.handleFrameMessage({ + message: messageFor(envelope1), + }); + + // Reset while the continuation is awaiting the check. Its `finally` + // must not clear the guard (it belongs to the superseded generation); + // `reset` clears it instead. + controller.reset(); + releaseCheck({ kycRequired: false }); + await first; + + // Re-establish a product-scoped flow and confirm the next completion + // continues again rather than being blocked forever by a stuck guard. + await controller.initialize({ email: 'a@b.co', product: 'ramps' }); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + await controller.handleFrameMessage({ + message: messageFor(envelope2), + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2); + }, + ); + }); + it('does not launch verification when the auto-run check fails', async () => { await withController( { @@ -830,7 +965,9 @@ describe('KycController', () => { return { kycRequired: true }; }); - const result = await controller.checkKycRequired({ product: 'ramps' }); + const result = await controller.checkKycRequired({ + product: 'ramps', + }); expect(result).toBe(false); expect(controller.state.phase).toBe('idle'); @@ -849,7 +986,9 @@ describe('KycController', () => { throw new Error('down'); }); - const result = await controller.checkKycRequired({ product: 'ramps' }); + const result = await controller.checkKycRequired({ + product: 'ramps', + }); expect(result).toBe(false); expect(controller.state.phase).toBe('idle'); @@ -985,6 +1124,34 @@ describe('KycController', () => { }); }); + it('refuses to refresh the token via onTokenExpiration after a reset', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let refreshError: unknown; + launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { + // The SDK stays open across a reset, then asks for a fresh token. + controller.reset(); + // Only the initial submitWrappedKey (session setup) should have run. + const callsBeforeRefresh = + handlers.submitWrappedKey.mock.calls.length; + try { + await onTokenExpiration(); + } catch (error) { + refreshError = error; + } + // The refresh must not hit the stale UKYC session. + expect(handlers.submitWrappedKey.mock.calls).toHaveLength( + callsBeforeRefresh, + ); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(refreshError).toBeInstanceOf(Error); + expect((refreshError as Error).message).toMatch(/flow was reset/u); + }); + }); + it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => { await withController(async ({ controller, launcher }) => { launcher.launch.mockImplementation(async ({ onStatusChange }) => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index c388338a75a..95a925e90e4 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -330,6 +330,17 @@ export class KycController extends BaseController< */ #generation = 0; + /** + * Guards the automatic post-authentication continuation. The Check/Auth + * frames can post more than one `complete` message (duplicate or late), each + * of which resolves to an `active` outcome with an access token. Without this + * flag, every such message would re-enter {@link #continueAfterAuthentication} + * and run the KYC-required check / SumSub sub-flow again while a prior run is + * still in flight. Set for the duration of a continuation and cleared by + * {@link reset} so a fresh flow can continue again. + */ + #continuationInFlight = false; + /** * Constructs a new {@link KycController}. * @@ -375,7 +386,7 @@ export class KycController extends BaseController< // this call's product (or `null`). Otherwise a prior run's product could // linger and cause `#continueAfterAuthentication` to auto-run the check / // sub-flow when the caller intended the manual (product-less) flow. - this.update((state) => { + this.#applyUpdate((state) => { if (params?.email) { state.email = params.email; } @@ -385,7 +396,7 @@ export class KycController extends BaseController< // Resolve country for display; non-blocking. try { const country = await this.messenger.call('KycService:getGeoCountry'); - this.update((state) => { + this.#applyUpdate((state) => { state.geoCountry = country; }); } catch { @@ -401,7 +412,7 @@ export class KycController extends BaseController< return; } - this.update((state) => { + this.#applyUpdate((state) => { state.phase = 'terms'; }); await this.loadDisclaimers(); @@ -420,7 +431,7 @@ export class KycController extends BaseController< this.state.geoCountry ?? (await this.messenger.call('KycService:getGeoCountry')); if (country !== this.state.geoCountry) { - this.update((state) => { + this.#applyUpdate((state) => { state.geoCountry = country; }); } @@ -428,12 +439,12 @@ export class KycController extends BaseController< 'KycService:fetchDisclaimers', { country }, ); - this.update((state) => { + this.#applyUpdate((state) => { state.disclaimers = disclaimers; state.disclaimersError = null; }); } catch (error) { - this.update((state) => { + this.#applyUpdate((state) => { state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; }); } @@ -457,7 +468,7 @@ export class KycController extends BaseController< const disclaimerIds = this.state.disclaimers.map( (disclaimer) => disclaimer.id, ); - this.update((state) => { + this.#applyUpdate((state) => { if (params?.email) { state.email = params.email; } @@ -491,7 +502,7 @@ export class KycController extends BaseController< // authentication. The Check/Auth frames re-populate these for the new // session. this.#authClientToken = null; - this.update((state) => { + this.#applyUpdate((state) => { state.error = null; state.phase = 'session'; state.statusMessage = 'Creating session...'; @@ -503,16 +514,19 @@ export class KycController extends BaseController< 'KycService:createSession', { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, ); - this.update((state) => { + this.#applyUpdate((state) => { state.sessionToken = sessionToken; state.phase = 'check'; state.statusMessage = 'Authenticating via Check frame...'; }); } catch (error) { - // Invalidate the stored acceptance so the customer can retry. - this.update((state) => { - state.termsAcceptedAt = null; - state.acceptedDisclaimerIds = []; + // Invalidate the stored acceptance so the customer can retry. Also clear + // `activeProduct` so a later `acceptTermsAndStartSession` that omits a + // product cannot auto-run the KYC check / SumSub chain for this failed + // flow's product — matching how `initialize` starts from a clean product. + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; state.error = `Session creation failed: ${String(error)}`; state.statusMessage = 'Session creation failed — accept the terms to try again.'; @@ -526,12 +540,25 @@ export class KycController extends BaseController< * Clears the persisted terms acceptance. */ clearSavedTerms(): void { - this.update((state) => { - state.termsAcceptedAt = null; - state.acceptedDisclaimerIds = []; + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); }); } + /** + * Clears the stored terms acceptance on the given draft state. Shared by the + * paths that must invalidate acceptance — explicit clear, vendor terms + * update, and session-creation failure — so they stay in sync. This is a + * targeted invalidation and, unlike {@link reset}, deliberately leaves the + * rest of the flow (geolocation, disclaimers, phase) untouched. + * + * @param state - The state to mutate. + */ + #clearAcceptedTerms(state: KycControllerState): void { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + } + /** * Handles a message posted by a Check/Auth frame and advances the flow. * @@ -566,7 +593,7 @@ export class KycController extends BaseController< const customerId = payload.payload?.customer?.id ?? null; if (customerId) { - this.update((state) => { + this.#applyUpdate((state) => { state.moonpayCustomerId = customerId; }); } @@ -617,7 +644,7 @@ export class KycController extends BaseController< clientToken?: string, ): Promise { if (status === 'active' && accessToken) { - this.update((state) => { + this.#applyUpdate((state) => { state.accessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Already authenticated. Review to submit.'; @@ -627,7 +654,7 @@ export class KycController extends BaseController< } if (status === 'connectionRequired' && clientToken) { this.#authClientToken = clientToken; - this.update((state) => { + this.#applyUpdate((state) => { state.phase = 'auth'; state.statusMessage = 'Verify your email via OTP in the Auth frame.'; }); @@ -651,7 +678,7 @@ export class KycController extends BaseController< accessToken?: string, ): Promise { if (status === 'active' && accessToken) { - this.update((state) => { + this.#applyUpdate((state) => { state.accessToken = accessToken; state.phase = 'form'; state.statusMessage = 'Authenticated. Review to submit.'; @@ -684,17 +711,35 @@ export class KycController extends BaseController< return; } - const kycRequired = await this.checkKycRequired({ product }); - if (!kycRequired) { + // A duplicate or late `complete` message can re-enter here while a prior + // continuation is still running; ignore it so the check / sub-flow does not + // run twice concurrently. + if (this.#continuationInFlight) { return; } + this.#continuationInFlight = true; + + // Capture the generation so a `reset()` landing mid-continuation does not + // let the `finally` clear a flag that belongs to a newer flow. + const generation = this.#generation; try { - await this.startSumSub(); - } catch { - // `startSumSub` already records `sumsub.status = 'failed'`; swallow the - // rethrown error (e.g. SDK unavailable) so the awaited continuation - // resolves cleanly rather than surfacing as an unhandled rejection. + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { + return; + } + + try { + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. + } + } finally { + if (this.#generation === generation) { + this.#continuationInFlight = false; + } } } @@ -702,9 +747,8 @@ export class KycController extends BaseController< * Invalidates stored terms and returns to the terms phase. */ #requireTermsReacceptance(): void { - this.update((state) => { - state.termsAcceptedAt = null; - state.acceptedDisclaimerIds = []; + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); state.phase = 'terms'; state.statusMessage = 'The vendor updated its Terms of Use — please re-accept.'; @@ -782,7 +826,7 @@ export class KycController extends BaseController< // while the HTTP call is in flight and avoid writing stale results. const generation = this.#generation; - this.update((state) => { + this.#applyUpdate((state) => { state.phase = 'submit'; state.statusMessage = 'Checking KYC status...'; }); @@ -797,7 +841,7 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return false; } - this.update((state) => { + this.#applyUpdate((state) => { state.kycRequiredByProduct[params.product] = kycRequired; state.lastCheckedAt = new Date().toISOString(); state.phase = 'done'; @@ -840,7 +884,7 @@ export class KycController extends BaseController< }): Promise> { if (!this.#sumsubLauncher.isAvailable()) { const error = 'SumSub SDK is not available in this runtime.'; - this.update((state) => { + this.#applyUpdate((state) => { state.sumsub.status = 'failed'; state.sumsub.result = { error }; }); @@ -853,7 +897,7 @@ export class KycController extends BaseController< const generation = this.#generation; try { - this.update((state) => { + this.#applyUpdate((state) => { state.sumsub.status = 'creatingSession'; state.sumsub.result = null; }); @@ -893,7 +937,7 @@ export class KycController extends BaseController< return {}; } - this.update((state) => { + this.#applyUpdate((state) => { state.sumsub.status = 'launching'; state.sumsub.applicantAccessToken = applicantAccessToken; }); @@ -906,6 +950,14 @@ export class KycController extends BaseController< const result = await this.#sumsubLauncher.launch({ applicantAccessToken, onTokenExpiration: async () => { + // A reset() may have superseded this flow while the SDK stayed open. + // Refuse to refresh against the now-stale UKYC session rather than + // silently keeping an orphaned SDK alive. + if (this.#generation !== generation) { + throw new Error( + 'KYC flow was reset; SumSub session is no longer active.', + ); + } const refreshed = await this.messenger.call( 'KycService:submitWrappedKey', exchange, @@ -951,7 +1003,11 @@ export class KycController extends BaseController< this.#authClientToken = null; // Invalidate any in-flight async work started before this reset. this.#generation += 1; - this.update((state) => { + // Allow the next authenticated flow to auto-continue; any continuation from + // the superseded generation will no longer clear this flag (see the + // generation guard in `#continueAfterAuthentication`). + this.#continuationInFlight = false; + this.#applyUpdate((state) => { state.phase = 'idle'; state.statusMessage = ''; state.error = null; @@ -987,17 +1043,38 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return false; } - this.update(updater); + this.#applyUpdate(updater); return true; } + /** + * The single state-update path for this controller. All mutations go through + * here (rather than calling `this.update` directly) so the mechanism stays + * consistent and one subtlety is handled in a single place: + * + * `sumsub.result` is typed as the recursive `Json`, and expanding + * `Draft` (which happens whenever an updater touches `sumsub.result`) + * trips TypeScript's "type instantiation is excessively deep" guard. By + * typing the callback parameter as the plain {@link KycControllerState} + * instead of Immer's `Draft`, we avoid expanding the draft type while keeping + * the same mutate-in-place semantics (the underlying value is still the Immer + * draft at runtime). + * + * @param updater - The state mutation to apply. + */ + #applyUpdate(updater: (state: KycControllerState) => void): void { + this.update((state) => { + updater(state as unknown as KycControllerState); + }); + } + /** * Transitions to the error phase with a message. * * @param message - The error message. */ #fail(message: string): void { - this.update((state) => { + this.#applyUpdate((state) => { state.error = message; state.phase = 'error'; }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index fec9a4baa2b..98a994c93b0 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -208,11 +208,17 @@ export class KycService { const location = await this.#messenger.call( 'GeolocationController:getGeolocation', ); - assert(location, string()); - const alpha2 = location.split('-')[0].toUpperCase(); + // Guard nullish/empty geolocation with the documented domain error rather + // than letting `assert(location, string())` surface a superstruct + // assertion error (which would change how the failure reads in + // `disclaimersError`). + const alpha2 = + typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; if (!alpha2 || alpha2 === 'UNKNOWN') { throw new Error( - `Unable to determine country from geolocation (got "${location}").`, + `Unable to determine country from geolocation (got "${String( + location, + )}").`, ); } const alpha3 = alpha2ToAlpha3(alpha2); @@ -261,7 +267,11 @@ export class KycService { method: 'POST', body: JSON.stringify(params), }); - return this.#validateResponse(data, CreateSessionResponseStruct, 'sessions'); + return this.#validateResponse( + data, + CreateSessionResponseStruct, + 'sessions', + ); } /** From cb246eddea3c2b618721a100c8bbb316ac085b21 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 12:25:07 +0200 Subject: [PATCH 14/67] fix: session state issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 47 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 14 ++++-- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 5e3e4797c14..d4c21cd6a70 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -299,12 +299,55 @@ describe('KycController', () => { ); }); + it('clears the old session token while a new session is being created', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let releaseSession: (value: { + sessionToken: string; + }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((resolve) => { + releaseSession = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // While the request is in flight (phase `session`) the stale token + // must already be gone so no Check frame URL can be built for it. + expect(controller.state.phase).toBe('session'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + + releaseSession({ sessionToken: 'new-session' }); + await pending; + + expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.buildCheckFrameUrl()).toContain( + 'sessionToken=new-session', + ); + }, + ); + }); + it('reverts to terms when session creation fails', async () => { await withController( { options: { state: { email: 'a@b.co', + sessionToken: 'old-session', disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], }, }, @@ -318,6 +361,10 @@ describe('KycController', () => { expect(controller.state.phase).toBe('terms'); expect(controller.state.termsAcceptedAt).toBeNull(); expect(controller.state.error).toMatch(/Session creation failed/u); + // A failed creation must not leave the old session token behind, so + // the Check frame cannot be built against an invalid session. + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); }, ); }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 95a925e90e4..3348659a7b3 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -496,16 +496,20 @@ export class KycController extends BaseController< } // A new session invalidates any authentication carried over from a prior - // session. Clear the stale access token and auth-frame client token so - // `buildAuthFrameUrl` cannot return a URL tied to an old client token and - // `checkKycRequired` cannot run with an access token from an earlier - // authentication. The Check/Auth frames re-populate these for the new - // session. + // session. Clear the stale session token, access token, and auth-frame + // client token so `buildCheckFrameUrl` cannot return a URL bound to an old + // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot + // return a URL tied to an old client token, and `checkKycRequired` cannot + // run with an access token from an earlier authentication. The Check/Auth + // frames re-populate these for the new session. Because `sessionToken` is + // cleared here and only re-set on success, a failed creation leaves it + // `null` rather than resurrecting the previous session. this.#authClientToken = null; this.#applyUpdate((state) => { state.error = null; state.phase = 'session'; state.statusMessage = 'Creating session...'; + state.sessionToken = null; state.accessToken = null; }); From 194d216fb7d30a2ea8cb2dfc36bde751cd224546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 12:28:42 +0200 Subject: [PATCH 15/67] fix: lockfile after rebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- yarn.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/yarn.lock b/yarn.lock index f8b525f11af..949feb617aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6231,7 +6231,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^77.8.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^78.0.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -6285,7 +6285,7 @@ __metadata: "@metamask/accounts-controller": "npm:^39.0.5" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^77.8.0" + "@metamask/bridge-controller": "npm:^78.0.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.0" "@metamask/keyring-controller": "npm:^27.1.0" @@ -8618,7 +8618,7 @@ __metadata: languageName: node linkType: hard -"@metamask/ramps-controller@npm:^17.0.0, @metamask/ramps-controller@workspace:packages/ramps-controller": +"@metamask/ramps-controller@npm:^17.1.0, @metamask/ramps-controller@workspace:packages/ramps-controller": version: 0.0.0-use.local resolution: "@metamask/ramps-controller@workspace:packages/ramps-controller" dependencies: @@ -9331,7 +9331,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" "@metamask/network-controller": "npm:^34.0.0" - "@metamask/ramps-controller": "npm:^17.0.0" + "@metamask/ramps-controller": "npm:^17.1.0" "@metamask/remote-feature-flag-controller": "npm:^4.2.2" "@metamask/sentinel-api-service": "npm:^1.0.0" "@metamask/transaction-controller": "npm:^69.2.1" From 85a764f5cd22753d0b15395115d577c46dac4409 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 14:09:31 +0200 Subject: [PATCH 16/67] fix: review again state management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 239 ++++++++++++++---- packages/kyc-controller/src/KycController.ts | 155 +++++++----- 2 files changed, 287 insertions(+), 107 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index d4c21cd6a70..1bcd8efd701 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -129,6 +129,35 @@ describe('KycController', () => { ); }); + it('does not restart an in-progress session flow', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'live-session', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + activeProduct: 'ramps', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'other@b.co', product: 'card' }); + + // A repeat initialize mid-flow must be a no-op: no new session, no + // token/phase teardown, and no clobbering of the active product. + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('check'); + expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.email).toBe('a@b.co'); + }, + ); + }); + it('stays on terms when terms exist but no email is available', async () => { await withController( { @@ -262,6 +291,7 @@ describe('KycController', () => { { options: { state: { + phase: 'check', email: 'a@b.co', sessionToken: 'old-session', accessToken: 'stale-access', @@ -369,6 +399,43 @@ describe('KycController', () => { ); }); + it('leaves the controller idle when reset() runs before session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (reason: Error) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // Reset while the create request is in flight, then let it fail. The + // superseded flow must not force the now-idle controller back to + // `terms` or re-run disclaimer loading. + controller.reset(); + rejectSession(new Error('nope')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + it('clears the active product when session creation fails', async () => { await withController( { @@ -461,17 +528,20 @@ describe('KycController', () => { }); it('captures the customer id and ignores a status-less complete message', async () => { - await withController(async ({ controller }) => { - const result = await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_1' }, - payload: { customer: { id: 'cust-1' } }, - }, - }); - expect(result).toStrictEqual({}); - expect(controller.state.moonpayCustomerId).toBe('cust-1'); - }); + await withController( + { options: { state: { phase: 'check' } } }, + async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); }); it('ignores messages on an unknown channel', async () => { @@ -487,9 +557,36 @@ describe('KycController', () => { }); }); + it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { + // Phase `done` (e.g. after a completed flow or a `reset()` that returns + // to an idle phase) means the Check frame is no longer active; a late or + // duplicate `ch_1` completion must not resurrect tokens or rewind phase. + await withController( + { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: envelope, + customer: { id: 'cust-late' }, + }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.phase).toBe('done'); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayCustomerId).toBeNull(); + }, + ); + }); + it('fails when credential decryption throws', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -507,7 +604,7 @@ describe('KycController', () => { describe('check frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-1', @@ -527,7 +624,7 @@ describe('KycController', () => { it('moves to auth on connectionRequired and enables the auth frame URL', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { clientToken: 'client-1', @@ -555,6 +652,7 @@ describe('KycController', () => { { options: { state: { + phase: 'check', sessionToken: 'tok', termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'], @@ -577,7 +675,7 @@ describe('KycController', () => { it('fails on an unexpected status', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, async ({ controller }) => { await controller.handleFrameMessage({ message: { @@ -595,7 +693,7 @@ describe('KycController', () => { describe('auth frame', () => { it('moves to form on an active status with an access token', async () => { await withController( - { options: { state: { sessionToken: 'tok' } } }, + { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, async ({ controller }) => { const envelope = envelopeFor(controller, { accessToken: 'access-2', @@ -614,29 +712,35 @@ describe('KycController', () => { }); it('requires re-acceptance on termsAcceptanceRequired', async () => { - await withController(async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'termsAcceptanceRequired' }, - }, - }); - expect(controller.state.phase).toBe('terms'); - }); + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + }, + ); }); it('fails on an unexpected status', async () => { - await withController(async ({ controller }) => { - await controller.handleFrameMessage({ - message: { - kind: 'complete', - meta: { channelId: 'ch_2' }, - payload: { status: 'unavailable' }, - }, - }); - expect(controller.state.phase).toBe('error'); - }); + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); }); }); }); @@ -644,7 +748,11 @@ describe('KycController', () => { describe('automatic post-authentication continuation', () => { it('stays at form and does not run the check when no product is set', async () => { await withController( - { options: { state: { sessionToken: 'tok', geoCountry: 'USA' } } }, + { + options: { + state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + }, + }, async ({ controller, handlers }) => { const envelope = envelopeFor(controller, { accessToken: 'access-1' }); @@ -667,6 +775,7 @@ describe('KycController', () => { { options: { state: { + phase: 'check', sessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', @@ -702,6 +811,7 @@ describe('KycController', () => { { options: { state: { + phase: 'auth', sessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', @@ -736,6 +846,7 @@ describe('KycController', () => { { options: { state: { + phase: 'check', sessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', @@ -766,6 +877,7 @@ describe('KycController', () => { { options: { state: { + phase: 'auth', sessionToken: 'tok', activeProduct: 'card', geoCountry: 'FRA', @@ -774,7 +886,9 @@ describe('KycController', () => { }, async ({ controller, handlers, launcher }) => { // Hold the KYC-required check open so the first continuation is still - // in flight when the second (duplicate) completion arrives. + // in flight when the second (duplicate) completion arrives. The first + // completion moves `phase` to `form` synchronously, so the duplicate + // is dropped by the frame-phase guard before it can re-run the check. let releaseCheck: (value: { kycRequired: boolean }) => void = () => { // no-op placeholder until the deferred promise is wired up }; @@ -812,9 +926,15 @@ describe('KycController', () => { { options: { state: { + phase: 'check', + email: 'a@b.co', sessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', + // Persisted terms so a post-reset `initialize` auto-recreates the + // session (reaching phase `check`) for the second completion. + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], }, }, }, @@ -828,7 +948,13 @@ describe('KycController', () => { const envelope2 = envelopeFor(controller, { accessToken: 'access-2', }); - const messageFor = (credentials: unknown) => ({ + const messageFor = ( + credentials: unknown, + ): { + kind: string; + meta: { channelId: string }; + payload: { status: string; credentials: unknown }; + } => ({ kind: 'complete', meta: { channelId: 'ch_1' }, payload: { status: 'active', credentials }, @@ -849,16 +975,17 @@ describe('KycController', () => { message: messageFor(envelope1), }); - // Reset while the continuation is awaiting the check. Its `finally` - // must not clear the guard (it belongs to the superseded generation); - // `reset` clears it instead. + // Reset while the continuation is awaiting the check. Its result is + // discarded by the generation guard (the check belongs to the + // superseded generation) rather than written onto the idle flow. controller.reset(); releaseCheck({ kycRequired: false }); await first; - // Re-establish a product-scoped flow and confirm the next completion - // continues again rather than being blocked forever by a stuck guard. - await controller.initialize({ email: 'a@b.co', product: 'ramps' }); + // Re-establish a product-scoped flow (auto-creates a session and + // returns to phase `check`) and confirm the next completion continues + // again rather than being blocked forever by a stuck guard. + await controller.initialize({ product: 'ramps' }); handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); await controller.handleFrameMessage({ message: messageFor(envelope2), @@ -874,6 +1001,7 @@ describe('KycController', () => { { options: { state: { + phase: 'check', sessionToken: 'tok', activeProduct: 'ramps', geoCountry: 'USA', @@ -1171,6 +1299,27 @@ describe('KycController', () => { }); }); + it('aborts without launching the SDK when reset() runs just before launch', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A reset() lands during the final token exchange, i.e. after the + // session is prepared but before the SDK is presented. + handlers.submitWrappedKey.mockImplementation(async () => { + controller.reset(); + return { status: 'ok', applicantAccessToken: 'aat' }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + // The SDK must not be opened on a flow that was reset to idle, and the + // `launching` status must not be written. + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.applicantAccessToken).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + it('refuses to refresh the token via onTokenExpiration after a reset', async () => { await withController(async ({ controller, handlers, launcher }) => { let refreshError: unknown; diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 3348659a7b3..a6436492679 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -37,6 +37,17 @@ const MOCK_JWT_TOKEN = 'mock-jwt-token'; // outcome) must not be recorded as `complete`. const SUMSUB_COMPLETED_STATUS = 'Completed'; +// Phases that represent an active vendor-session flow (tokens issued and/or +// Check/Auth frames in progress). A repeat `initialize` while in one of these +// must not restart the session and disrupt the in-flight flow. +const IN_PROGRESS_PHASES: KycPhase[] = [ + 'session', + 'check', + 'auth', + 'form', + 'submit', +]; + // === STATE === /** @@ -330,17 +341,6 @@ export class KycController extends BaseController< */ #generation = 0; - /** - * Guards the automatic post-authentication continuation. The Check/Auth - * frames can post more than one `complete` message (duplicate or late), each - * of which resolves to an `active` outcome with an access token. Without this - * flag, every such message would re-enter {@link #continueAfterAuthentication} - * and run the KYC-required check / SumSub sub-flow again while a prior run is - * still in flight. Set for the duration of a continuation and cleared by - * {@link reset} so a fresh flow can continue again. - */ - #continuationInFlight = false; - /** * Constructs a new {@link KycController}. * @@ -382,6 +382,15 @@ export class KycController extends BaseController< email?: string; product?: KycProduct; }): Promise { + // A repeat `initialize` while a session flow is already in progress must + // not tear it down: creating a new vendor session clears the tokens and + // forces `phase` back through `session`/`check`, breaking an in-flight + // Check/Auth frame flow. Leave the active flow untouched and let the + // consumer drive it (or call `reset` first to start over). + if (IN_PROGRESS_PHASES.includes(this.state.phase)) { + return; + } + // `initialize` starts a fresh flow, so `activeProduct` is always reset to // this call's product (or `null`). Otherwise a prior run's product could // linger and cause `#continueAfterAuthentication` to auto-run the check / @@ -393,10 +402,15 @@ export class KycController extends BaseController< state.activeProduct = params?.product ?? null; }); + // Capture the flow generation so a `reset()` landing while the async + // geolocation / session steps below are in flight cannot write results + // onto an idle controller. + const generation = this.#generation; + // Resolve country for display; non-blocking. try { const country = await this.messenger.call('KycService:getGeoCountry'); - this.#applyUpdate((state) => { + this.#updateIfCurrent(generation, (state) => { state.geoCountry = country; }); } catch { @@ -425,13 +439,17 @@ export class KycController extends BaseController< * @param params.country - ISO 3166-1 alpha-3 country code override. */ async loadDisclaimers(params?: { country?: string }): Promise { + // Capture the flow generation so a `reset()` landing while the geo / + // disclaimers requests are in flight cannot write results onto an idle + // controller. + const generation = this.#generation; try { const country = params?.country ?? this.state.geoCountry ?? (await this.messenger.call('KycService:getGeoCountry')); if (country !== this.state.geoCountry) { - this.#applyUpdate((state) => { + this.#updateIfCurrent(generation, (state) => { state.geoCountry = country; }); } @@ -439,12 +457,12 @@ export class KycController extends BaseController< 'KycService:fetchDisclaimers', { country }, ); - this.#applyUpdate((state) => { + this.#updateIfCurrent(generation, (state) => { state.disclaimers = disclaimers; state.disclaimersError = null; }); } catch (error) { - this.#applyUpdate((state) => { + this.#updateIfCurrent(generation, (state) => { state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; }); } @@ -504,6 +522,11 @@ export class KycController extends BaseController< // frames re-populate these for the new session. Because `sessionToken` is // cleared here and only re-set on success, a failed creation leaves it // `null` rather than resurrecting the previous session. + // Capture the flow generation so a `reset()` landing while the create + // request is in flight cannot resurrect a session (success) or overwrite + // the now-idle controller (failure). The synchronous update below runs + // before any `await`, so it needs no guard. + const generation = this.#generation; this.#authClientToken = null; this.#applyUpdate((state) => { state.error = null; @@ -518,12 +541,17 @@ export class KycController extends BaseController< 'KycService:createSession', { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, ); - this.#applyUpdate((state) => { + this.#updateIfCurrent(generation, (state) => { state.sessionToken = sessionToken; state.phase = 'check'; state.statusMessage = 'Authenticating via Check frame...'; }); } catch (error) { + // A reset() superseded this flow while the request was in flight; leave + // the idle controller alone rather than forcing it back to `terms`. + if (this.#generation !== generation) { + return; + } // Invalidate the stored acceptance so the customer can retry. Also clear // `activeProduct` so a later `acceptTermsAndStartSession` that omits a // product cannot auto-run the KYC check / SumSub chain for this failed @@ -592,6 +620,23 @@ export class KycController extends BaseController< } const channelId = payload.meta?.channelId; + + // Only honor a Check/Auth `complete` for the frame the flow is currently + // waiting on. This drops stale or duplicate messages — e.g. a late post + // after `reset()` (phase `idle`) or after the flow already advanced past + // this frame — so they cannot resurrect tokens or rewind `phase` on a + // controller that has moved on. Frame messages are external input and, + // unlike the async steps, are not covered by the `#generation` guard. + let expectedPhase: KycPhase | null = null; + if (channelId === CHANNEL_CHECK) { + expectedPhase = 'check'; + } else if (channelId === CHANNEL_AUTH) { + expectedPhase = 'auth'; + } + if (!expectedPhase || this.state.phase !== expectedPhase) { + return {}; + } + const status = payload.payload?.status; const credsEnvelope = payload.payload?.credentials; @@ -627,11 +672,8 @@ export class KycController extends BaseController< return {}; } - if (channelId === CHANNEL_AUTH) { - await this.#handleAuthOutcome(status, accessToken); - return {}; - } - + // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above. + await this.#handleAuthOutcome(status, accessToken); return {}; } @@ -715,35 +757,24 @@ export class KycController extends BaseController< return; } - // A duplicate or late `complete` message can re-enter here while a prior - // continuation is still running; ignore it so the check / sub-flow does not - // run twice concurrently. - if (this.#continuationInFlight) { + // Re-entry protection lives at the frame boundary: `handleFrameMessage` + // only honors a Check/Auth `complete` while `phase` matches, and both + // outcome handlers move `phase` to `form` before awaiting this method. A + // duplicate or late `complete` therefore lands after the phase moved on and + // is dropped before it can start a second continuation. Any writes here are + // additionally guarded by `#generation` (see `checkKycRequired` / + // `startSumSub`) so a `reset()` mid-continuation cannot corrupt state. + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { return; } - this.#continuationInFlight = true; - - // Capture the generation so a `reset()` landing mid-continuation does not - // let the `finally` clear a flag that belongs to a newer flow. - const generation = this.#generation; try { - const kycRequired = await this.checkKycRequired({ product }); - if (!kycRequired) { - return; - } - - try { - await this.startSumSub(); - } catch { - // `startSumSub` already records `sumsub.status = 'failed'`; swallow the - // rethrown error (e.g. SDK unavailable) so the awaited continuation - // resolves cleanly rather than surfacing as an unhandled rejection. - } - } finally { - if (this.#generation === generation) { - this.#continuationInFlight = false; - } + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. } } @@ -842,15 +873,15 @@ export class KycController extends BaseController< ); // The flow was reset while the check was in flight; discard the result // rather than resurrecting a done/cached state on an idle controller. - if (this.#generation !== generation) { - return false; - } - this.#applyUpdate((state) => { + const applied = this.#updateIfCurrent(generation, (state) => { state.kycRequiredByProduct[params.product] = kycRequired; state.lastCheckedAt = new Date().toISOString(); state.phase = 'done'; state.statusMessage = 'KYC check complete.'; }); + if (!applied) { + return false; + } return kycRequired; } catch (error) { if (this.#generation !== generation) { @@ -935,16 +966,19 @@ export class KycController extends BaseController< exchange, ); - // A reset() landed while the session/token was being prepared; abort - // before presenting the SDK rather than launching on an idle controller. - if (this.#generation !== generation) { - return {}; - } - - this.#applyUpdate((state) => { + // A reset() may have landed while the session/token was being prepared. + // Gate the `launching` write and the decision to open the SDK behind a + // single generation check: `#updateIfCurrent` only writes when still + // current and reports whether it did. Since there is no `await` between + // this check and `launch` below, a successful result guarantees the SDK + // is never presented on a flow that a concurrent reset() returned to idle. + const stillCurrent = this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'launching'; state.sumsub.applicantAccessToken = applicantAccessToken; }); + if (!stillCurrent) { + return {}; + } // Track whether the SDK ever reported a successful completion. A resolved // `launch` alone does not imply success — the applicant may have @@ -1005,12 +1039,9 @@ export class KycController extends BaseController< */ reset(): void { this.#authClientToken = null; - // Invalidate any in-flight async work started before this reset. + // Invalidate any in-flight async work started before this reset so its + // results are discarded rather than written onto the now-idle controller. this.#generation += 1; - // Allow the next authenticated flow to auto-continue; any continuation from - // the superseded generation will no longer clear this flag (see the - // generation guard in `#continueAfterAuthentication`). - this.#continuationInFlight = false; this.#applyUpdate((state) => { state.phase = 'idle'; state.statusMessage = ''; From ed8497cdbcb2122c154f28c46dc31e190f25bf5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 14:23:45 +0200 Subject: [PATCH 17/67] feat: update architecture section of readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/README.md | 155 ++++++++++++++++++------------ 1 file changed, 93 insertions(+), 62 deletions(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 5c9d3a83308..a8d4ad915f0 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -22,7 +22,6 @@ This watches `src/**/*.ts` and re-runs the build on each change (it also perform This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). - ## Architecture `@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns @@ -47,13 +46,13 @@ This document explains: The package is built around a few deliberate constraints: -| Principle | How it shows up in the code | -| --- | --- | -| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | -| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | -| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | -| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | -| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | --- @@ -150,14 +149,14 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): Endpoints: -| Method | HTTP | Endpoint | Purpose | -| --- | --- | --- | --- | -| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | -| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | -| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | -| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | -| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow | -| `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | +| Method | HTTP | Endpoint | Purpose | +| ------------------- | ------ | --------------------------------------- | -------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow | +| `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | ### 2.3 `crypto.ts` @@ -242,7 +241,7 @@ stateDiagram-v2 terms --> session : acceptTermsAndStartSession() session --> check : createSession() ok - session --> terms : createSession() fails (clears saved terms) + session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) check --> form : Check frame → active (already authenticated) check --> auth : Check frame → connectionRequired (needs OTP) @@ -269,19 +268,31 @@ stateDiagram-v2 > sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops > at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. +> **`initialize` never tears down an active flow.** If `phase` is already one of +> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a +> repeat `initialize` is a **no-op** — it will not create a new session, clear +> tokens, or reset `activeProduct`. Call `reset()` first to start over. + +> **`reset()` is callable from any phase and supersedes in-flight work.** In +> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, +> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any +> still-pending async step (geolocation, disclaimers, session creation, the +> KYC-required check, or the SumSub sub-flow) discards its result instead of +> writing it onto the now-idle controller. + Phase meanings (from `types.ts`): -| Phase | Meaning | -| --- | --- | -| `idle` | Nothing started. | -| `terms` | Waiting for the customer to accept vendor terms. | -| `session` | Creating the vendor session. | -| `check` | Running the **invisible** connection-check frame. | -| `auth` | Running the **visible** authentication (email OTP) frame. | -| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | -| `submit` | Submitting the KYC-required check. | -| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | -| `error` | Halted — see `error`. | +| Phase | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | --- @@ -381,12 +392,22 @@ sequenceDiagram Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } UI->>Ctrl: handleFrameMessage({ message }) - Note over Ctrl: 1. store customer.id (moonpayCustomerId)
2. decryptCredentials(envelope, privKey)
3. route by channelId (ch_1 Check / ch_2 Auth) + Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) Ctrl->>Ctrl: apply outcome → next phase ``` Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. +> **Phase-guarded intake.** A `complete` is only processed when the flow is +> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` +> while `phase === 'auth'`. Because both outcome handlers advance `phase` to +> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` +> (delivered once the flow has moved on) is dropped before any state is touched, +> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. +> Frame messages are external input and are not covered by the `#generation` +> guard used for the controller's own async steps, so this boundary check is how +> late frame posts are neutralized. + Credential decryption (`crypto.ts`): ```mermaid @@ -424,14 +445,23 @@ stateDiagram-v2 idle --> creatingSession : startSumSub() creatingSession --> fetchingToken : createUkycSession() ok fetchingToken --> launching : submitWrappedKey() ok - launching --> inProgress : launcher.onStatusChange - inProgress --> complete : status = Completed - launching --> complete : SDK resolves + launching --> inProgress : onStatusChange (non-Completed) + launching --> complete : onStatusChange = Completed + inProgress --> complete : onStatusChange = Completed + launching --> failed : resolves without a Completed status + inProgress --> failed : resolves without a Completed status creatingSession --> failed : error fetchingToken --> failed : error launching --> failed : launcher unavailable / error ``` +> **Completion is status-driven, not resolution-driven.** A resolved `launch` +> is only recorded as `complete` when the SDK reported the `Completed` status +> via `onStatusChange` at least once. If `launch` resolves without ever having +> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success +> outcome), the controller records `failed` — so consumers never mistake an +> unfinished flow for a verified one. + The `KycSumSubLauncher` interface (injected per client): ```ts @@ -442,8 +472,10 @@ type KycSumSubLauncher = { ``` `launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the -controller re-runs `submitWrappedKey` to refresh), and an `onStatusChange` -callback that the controller maps into `sumsub.status`. +controller re-runs `submitWrappedKey` to refresh — but **refuses to refresh +after a `reset()`**, throwing instead so a still-open SDK cannot keep an +orphaned UKYC session alive), and an `onStatusChange` callback that the +controller maps into `sumsub.status`. --- @@ -609,40 +641,39 @@ graph LR shared -. injected adapters .- client ``` -| Concern | Owner | -| --- | --- | -| Flow phase machine & state | `KycController` (shared) | -| UKYC HTTP + validation + retries | `KycService` (shared) | -| Credential decryption / key exchange | `crypto.ts` (shared) | -| Frame message semantics | `KycController.handleFrameMessage` (shared) | -| Frame **transport** (WebView/iframe) | Client | -| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | -| Auth bearer token / geolocation | Other controllers (via messenger) | -| Persistence of state | Client (base-controller persistence) | +| Concern | Owner | +| ------------------------------------ | ------------------------------------------- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | --- ### Appendix — key source files -| File | Responsibility | -| --- | --- | +| File | Responsibility | +| ---------------------- | ----------------------------------------------------- | | `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | -| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | -| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | -| `src/selectors.ts` | Memoized selectors over controller state. | -| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | -| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | -| `src/index.ts` | Public exports (no barrel wildcards). | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | Reference client (metamask-mobile): -| File | Responsibility | -| --- | --- | -| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | -| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | -| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | -| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | -| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | -| `app/selectors/kycController.ts` | Redux selectors. | - +| File | Responsibility | +| -------------------------------------------------------------- | --------------------------------------- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | From 3d2cf99249793abaf64eb0430c93aa0852879d62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 17:36:42 +0200 Subject: [PATCH 18/67] feat: update codeowner files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .github/CODEOWNERS | 7 ++++++- codeowners.ts | 8 ++++++++ teams.json | 3 ++- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 360764abba6..d535abd6be9 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,6 +75,9 @@ ## Product Safety Team /packages/phishing-controller @MetaMask/product-safety +## Universal KYC Team +/packages/kyc-controller @MetaMask/universal-kyc + ## Swaps-Bridge Team /packages/bridge-controller @MetaMask/swaps-engineers /packages/bridge-status-controller @MetaMask/swaps-engineers @@ -286,4 +289,6 @@ /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/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform \ No newline at end of file +/packages/snap-account-service/CHANGELOG.md @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 \ No newline at end of file diff --git a/codeowners.ts b/codeowners.ts index 2a864d499ba..3adf343309c 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -185,6 +185,9 @@ const PACKAGES: Record = { teams: ['@MetaMask/accounts-engineers', '@MetaMask/core-platform'], initializationPath: 'keyring-controller', }, + 'kyc-controller': { + teams: ['@MetaMask/universal-kyc'], + }, 'local-node-utils': { teams: [ '@MetaMask/mobile-platform', @@ -492,6 +495,10 @@ function buildTeamSections(): CodeownersSection[] { title: 'Product Safety Team', rules: [buildRuleForPackage('phishing-controller')], }, + { + title: 'Universal KYC Team', + rules: [buildRuleForPackage('kyc-controller')], + }, { title: 'Swaps-Bridge Team', rules: [ @@ -690,6 +697,7 @@ function buildPackageReleaseSection(): CodeownersSection { 'chomp-api-service', 'money-account-upgrade-controller', 'snap-account-service', + 'kyc-controller', ] as const satisfies (keyof typeof PACKAGES)[]; return { diff --git a/teams.json b/teams.json index b20ada66d97..8c95f3e411b 100644 --- a/teams.json +++ b/teams.json @@ -94,5 +94,6 @@ "metamask/money-account-upgrade-controller": "team-earn", "metamask/snap-account-service": "team-accounts-framework", "metamask/platform-api-docs": "team-core-platform", - "metamask/smart-transactions-controller": "team-transactions" + "metamask/smart-transactions-controller": "team-transactions", + "metamask/kyc-controller": "team-universal-kyc" } From 2c4805df0289b36c23dfa242a51aa6fb286ed940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 24 Jul 2026 18:08:35 +0200 Subject: [PATCH 19/67] fix: lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/README.md | 2 +- packages/kyc-controller/src/KycService.test.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index a8d4ad915f0..84906e80e89 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -674,6 +674,6 @@ Reference client (metamask-mobile): | `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | | `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | | `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | | `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | | `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index c359add4e76..86f582538ab 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -255,7 +255,9 @@ describe('KycService', () => { describe('baseUrl override', () => { it('uses the provided baseUrl instead of the env-derived URL', async () => { const customUrl = 'https://kyc-api.local.test'; - const disclaimers = [{ id: '1', display_name: 'Terms', url: 'https://t' }]; + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; nock(customUrl) .get('/vendors/moonpay/disclaimers') .query({ country: 'USA' }) From 1983026d4e2dc9fa15c541c9e6425a33006be8c1 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Mon, 27 Jul 2026 13:50:07 -0700 Subject: [PATCH 20/67] replace idosSessionId with sessionId --- packages/kyc-controller/src/KycController.test.ts | 3 --- packages/kyc-controller/src/KycController.ts | 4 ++-- packages/kyc-controller/src/KycService.test.ts | 3 --- packages/kyc-controller/src/KycService.ts | 4 +--- 4 files changed, 3 insertions(+), 11 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 1bcd8efd701..611d0bb5e3d 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1202,7 +1202,6 @@ describe('KycController', () => { handlers.createUkycSession.mockResolvedValue({ sessionId: 'sid', wrappingPublicKey: 'wpk', - idosSessionId: 'idos', }); handlers.submitWrappedKey.mockResolvedValue({ status: 'ok', @@ -1284,7 +1283,6 @@ describe('KycController', () => { return { sessionId: 'sid', wrappingPublicKey: 'wpk', - idosSessionId: 'idos', }; }); @@ -1486,7 +1484,6 @@ function withController( createUkycSession: jest.fn().mockResolvedValue({ sessionId: 'sid', wrappingPublicKey: 'wpk', - idosSessionId: 'idos', }), submitWrappedKey: jest .fn() diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index a6436492679..38f1cc3ae8f 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -547,6 +547,7 @@ export class KycController extends BaseController< state.statusMessage = 'Authenticating via Check frame...'; }); } catch (error) { + console.error('Session creation failed:', error); // A reset() superseded this flow while the request was in flight; leave // the idle controller alone rather than forcing it back to `terms`. if (this.#generation !== generation) { @@ -938,7 +939,7 @@ export class KycController extends BaseController< }); const jwtToken = MOCK_JWT_TOKEN; - const { sessionId, wrappingPublicKey, idosSessionId } = + const { sessionId, wrappingPublicKey } = await this.messenger.call('KycService:createUkycSession', { jwtToken, vendorMetadata: { @@ -952,7 +953,6 @@ export class KycController extends BaseController< const exchange = { sessionId, wrappedUserKey: wrappingPublicKey, - idosSessionId, jwtToken, }; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 86f582538ab..3e388f0e95e 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -196,7 +196,6 @@ describe('KycService', () => { const response = { sessionId: 'sid', wrappingPublicKey: 'wpk', - idosSessionId: 'idos', }; nock(MOCK_API_URL).post('/sessions').reply(200, response); const { service } = getService(); @@ -229,7 +228,6 @@ describe('KycService', () => { await service.submitWrappedKey({ sessionId: 'sid', wrappedUserKey: 'wuk', - idosSessionId: 'idos', jwtToken: 'jwt', }), ).toStrictEqual(response); @@ -245,7 +243,6 @@ describe('KycService', () => { service.submitWrappedKey({ sessionId: 'sid', wrappedUserKey: 'wuk', - idosSessionId: 'idos', jwtToken: 'jwt', }), ).rejects.toThrow(/Malformed response received from wrapped-key API/u); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 98a994c93b0..2b854e9faf3 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -114,7 +114,6 @@ const KycRequiredResponseStruct = type({ required: boolean() }); const UkycSessionResponseStruct = type({ sessionId: string(), wrappingPublicKey: string(), - idosSessionId: string(), }); export type UkycSessionResponse = Infer; @@ -146,7 +145,6 @@ export type CreateUkycSessionParams = { export type SubmitWrappedKeyParams = { sessionId: string; wrappedUserKey: string; - idosSessionId: string; jwtToken: string; }; @@ -345,7 +343,7 @@ export class KycService { body: JSON.stringify({ wrappedUserKey: params.wrappedUserKey, jwtToken: params.jwtToken, - idosSessionId: params.idosSessionId, + sessionId: params.sessionId, }), }); return this.#validateResponse( From a1fcb5bec55e8c29a715102110de3047bf57c291 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Mon, 27 Jul 2026 16:15:56 -0700 Subject: [PATCH 21/67] Initial key derivation, wrapping, and submission --- .../src/KycController-method-action-types.ts | 13 +- .../kyc-controller/src/KycController.test.ts | 127 +++++++-- packages/kyc-controller/src/KycController.ts | 130 ++++++++-- .../src/KycService-method-action-types.ts | 55 +++- .../kyc-controller/src/KycService.test.ts | 108 +++++++- packages/kyc-controller/src/KycService.ts | 204 ++++++++++++--- packages/kyc-controller/src/index.ts | 50 +++- packages/kyc-controller/src/ukyc/constants.ts | 59 +++++ .../src/ukyc/deriveClientMaterial.test.ts | 109 ++++++++ .../src/ukyc/deriveClientMaterial.ts | 128 +++++++++ .../kyc-controller/src/ukyc/encoding.test.ts | 32 +++ packages/kyc-controller/src/ukyc/encoding.ts | 40 +++ packages/kyc-controller/src/ukyc/index.ts | 43 +++ .../kyc-controller/src/ukyc/jwtChain.test.ts | 100 +++++++ packages/kyc-controller/src/ukyc/jwtChain.ts | 114 ++++++++ .../src/ukyc/localUserSecret.test.ts | 148 +++++++++++ .../src/ukyc/localUserSecret.ts | 165 ++++++++++++ .../src/ukyc/storageAccessToken.test.ts | 226 ++++++++++++++++ .../src/ukyc/storageAccessToken.ts | 245 ++++++++++++++++++ .../src/ukyc/wrapEncryptionKey.test.ts | 91 +++++++ .../src/ukyc/wrapEncryptionKey.ts | 62 +++++ .../src/ukyc/wrapUserKey.test.ts | 114 ++++++++ .../kyc-controller/src/ukyc/wrapUserKey.ts | 101 ++++++++ .../src/ukyc/wrappedRelayPayload.test.ts | 90 +++++++ .../src/ukyc/wrappedRelayPayload.ts | 65 +++++ 25 files changed, 2520 insertions(+), 99 deletions(-) create mode 100644 packages/kyc-controller/src/ukyc/constants.ts create mode 100644 packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts create mode 100644 packages/kyc-controller/src/ukyc/deriveClientMaterial.ts create mode 100644 packages/kyc-controller/src/ukyc/encoding.test.ts create mode 100644 packages/kyc-controller/src/ukyc/encoding.ts create mode 100644 packages/kyc-controller/src/ukyc/index.ts create mode 100644 packages/kyc-controller/src/ukyc/jwtChain.test.ts create mode 100644 packages/kyc-controller/src/ukyc/jwtChain.ts create mode 100644 packages/kyc-controller/src/ukyc/localUserSecret.test.ts create mode 100644 packages/kyc-controller/src/ukyc/localUserSecret.ts create mode 100644 packages/kyc-controller/src/ukyc/storageAccessToken.test.ts create mode 100644 packages/kyc-controller/src/ukyc/storageAccessToken.ts create mode 100644 packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts create mode 100644 packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts create mode 100644 packages/kyc-controller/src/ukyc/wrapUserKey.test.ts create mode 100644 packages/kyc-controller/src/ukyc/wrapUserKey.ts create mode 100644 packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts create mode 100644 packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 92e34d45eb9..9b81dc9c078 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -127,9 +127,16 @@ export type KycControllerGetKycStatusAction = { }; /** - * Runs the SumSub document-verification sub-flow: creates a UKYC session, - * exchanges the wrapped key for an applicant access token, and presents the - * SDK via the injected launcher. + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. requests a per-session wrapping key from the UKYC backend; + * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the + * attested session server public key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. creates the UKYC session (handing over the wrapped key); + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 611d0bb5e3d..46edcfb405a 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -13,6 +13,29 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; import { KycController } from './KycController'; import type { KycControllerMessenger } from './KycController'; import type { KycSumSubLauncher } from './types'; +import { verifyJwtChain, wrapEncryptionKey } from './ukyc'; + +// `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) +// need a real signed chain / valid keys, so they are stubbed here; the rest of +// the UKYC layer (local-user-secret storage adapter, client-material +// derivation) runs for real so the controller's messenger wiring is exercised. +// Return values are (re)configured per test in `withController` because the +// shared jest config enables `resetMocks`. +jest.mock('./ukyc', () => { + const actual = jest.requireActual('./ukyc'); + return { + ...actual, + verifyJwtChain: jest.fn(), + wrapEncryptionKey: jest.fn(), + }; +}); + +const mockVerifyJwtChain = verifyJwtChain as jest.MockedFunction< + typeof verifyJwtChain +>; +const mockWrapEncryptionKey = wrapEncryptionKey as jest.MockedFunction< + typeof wrapEncryptionKey +>; /** * Builds an encrypted envelope for a recipient's X25519 public key. @@ -1199,14 +1222,6 @@ describe('KycController', () => { it('runs the full sub-flow and completes', async () => { await withController(async ({ controller, handlers, launcher }) => { - handlers.createUkycSession.mockResolvedValue({ - sessionId: 'sid', - wrappingPublicKey: 'wpk', - }); - handlers.submitWrappedKey.mockResolvedValue({ - status: 'ok', - applicantAccessToken: 'aat', - }); launcher.launch.mockImplementation( async ({ onStatusChange, onTokenExpiration }) => { onStatusChange?.('idle', 'InProgress'); @@ -1224,8 +1239,35 @@ describe('KycController', () => { expect(result).toStrictEqual({ ok: true }); expect(controller.state.sumsub.status).toBe('complete'); expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); - // onTokenExpiration re-invokes the exchange. - expect(handlers.submitWrappedKey).toHaveBeenCalledTimes(2); + // The wrapped key is handed over once at session creation. + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ + wrappedEncryptionKey: expect.objectContaining({ + sessionId: 'wk', + encryptedKey: 'enc', + }), + }), + ); + // onTokenExpiration re-fetches the applicant access token. + expect(handlers.fetchApplicantAccessToken).toHaveBeenCalledTimes(2); + }); + }); + + it('aborts when the attested session server public key does not match', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.getWrappingKey.mockResolvedValue({ + id: 'wk', + jwtChain: 'jwt.chain.sig', + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'tampered' }, + }); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining('sessionServerPublicKey does not match'), + }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(launcher.launch).not.toHaveBeenCalled(); }); }); @@ -1282,7 +1324,7 @@ describe('KycController', () => { controller.reset(); return { sessionId: 'sid', - wrappingPublicKey: 'wpk', + idosSessionId: 'iss', }; }); @@ -1301,7 +1343,7 @@ describe('KycController', () => { await withController(async ({ controller, handlers, launcher }) => { // A reset() lands during the final token exchange, i.e. after the // session is prepared but before the SDK is presented. - handlers.submitWrappedKey.mockImplementation(async () => { + handlers.fetchApplicantAccessToken.mockImplementation(async () => { controller.reset(); return { status: 'ok', applicantAccessToken: 'aat' }; }); @@ -1324,16 +1366,17 @@ describe('KycController', () => { launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { // The SDK stays open across a reset, then asks for a fresh token. controller.reset(); - // Only the initial submitWrappedKey (session setup) should have run. + // Only the initial fetchApplicantAccessToken (session setup) should + // have run. const callsBeforeRefresh = - handlers.submitWrappedKey.mock.calls.length; + handlers.fetchApplicantAccessToken.mock.calls.length; try { await onTokenExpiration(); } catch (error) { refreshError = error; } // The refresh must not hit the stale UKYC session. - expect(handlers.submitWrappedKey.mock.calls).toHaveLength( + expect(handlers.fetchApplicantAccessToken.mock.calls).toHaveLength( callsBeforeRefresh, ); return { ok: true }; @@ -1418,8 +1461,12 @@ type ServiceHandlers = { fetchDisclaimers: jest.Mock; createSession: jest.Mock; checkKycRequired: jest.Mock; + getWrappingKey: jest.Mock; + fetchJwks: jest.Mock; createUkycSession: jest.Mock; - submitWrappedKey: jest.Mock; + fetchApplicantAccessToken: jest.Mock; + performGetStorage: jest.Mock; + performSetStorage: jest.Mock; }; type Launcher = { @@ -1443,8 +1490,12 @@ const SERVICE_ACTIONS = [ 'KycService:fetchDisclaimers', 'KycService:createSession', 'KycService:checkKycRequired', + 'KycService:getWrappingKey', + 'KycService:fetchJwks', 'KycService:createUkycSession', - 'KycService:submitWrappedKey', + 'KycService:fetchApplicantAccessToken', + 'UserStorageController:performGetStorage', + 'UserStorageController:performSetStorage', ] as const; /** @@ -1481,13 +1532,23 @@ function withController( fetchDisclaimers: jest.fn().mockResolvedValue([]), createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + getWrappingKey: jest.fn().mockResolvedValue({ + id: 'wk', + jwtChain: 'jwt.chain.sig', + // Matches the `sessionServerPublicKeyX` returned by the mocked + // `verifyJwtChain`, so the attestation check passes. + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + }), + fetchJwks: jest.fn().mockResolvedValue({ keys: [] }), createUkycSession: jest.fn().mockResolvedValue({ sessionId: 'sid', - wrappingPublicKey: 'wpk', + idosSessionId: 'iss', }), - submitWrappedKey: jest + fetchApplicantAccessToken: jest .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + performGetStorage: jest.fn().mockResolvedValue(null), + performSetStorage: jest.fn().mockResolvedValue(undefined), }; rootMessenger.registerActionHandler( 'KycService:getGeoCountry', @@ -1505,15 +1566,39 @@ function withController( 'KycService:checkKycRequired', handlers.checkKycRequired, ); + rootMessenger.registerActionHandler( + 'KycService:getWrappingKey', + handlers.getWrappingKey, + ); + rootMessenger.registerActionHandler('KycService:fetchJwks', handlers.fetchJwks); rootMessenger.registerActionHandler( 'KycService:createUkycSession', handlers.createUkycSession, ); rootMessenger.registerActionHandler( - 'KycService:submitWrappedKey', - handlers.submitWrappedKey, + 'KycService:fetchApplicantAccessToken', + handlers.fetchApplicantAccessToken, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorage', + handlers.performGetStorage, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performSetStorage', + handlers.performSetStorage, ); + // Configure the mocked UKYC crypto for this test (reset before each test by + // the shared jest config). + mockVerifyJwtChain.mockReturnValue({ + sessionServerPublicKeyX: 'spk-x', + nonce: 'n', + }); + mockWrapEncryptionKey.mockReturnValue({ + encryptedKey: 'enc', + nonce: 'nonce', + }); + const launcher: Launcher = { isAvailable: jest.fn().mockReturnValue(true), launch: jest.fn().mockResolvedValue({ ok: true }), diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 38f1cc3ae8f..b6c3e4dc7df 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -5,7 +5,12 @@ import type { } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; +import type { + UserStorageControllerPerformGetStorageAction, + UserStorageControllerPerformSetStorageAction, +} from '@metamask/profile-sync-controller/user-storage'; import type { Json } from '@metamask/utils'; +import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto'; import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto'; @@ -18,6 +23,14 @@ import type { KycSumSubLauncher, KycSumSubStatus, } from './types'; +import { + deriveClientMaterial, + getOrCreateLocalUserSecret, + toBase64Url, + verifyJwtChain, + wrapEncryptionKey, +} from './ukyc'; +import type { UkycLocalUserSecretStore } from './ukyc'; // === GENERAL === @@ -263,7 +276,10 @@ export type KycControllerActions = | KycControllerGetStateAction | KycControllerMethodActions; -type AllowedActions = KycServiceMethodActions; +type AllowedActions = + | KycServiceMethodActions + | UserStorageControllerPerformGetStorageAction + | UserStorageControllerPerformSetStorageAction; export type KycControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, @@ -366,6 +382,38 @@ export class KycController extends BaseController< ); } + /** + * Builds an adapter over `UserStorageController` that the platform-agnostic + * `getOrCreateLocalUserSecret` helper uses to persist/load the UKYC + * `local_user_secret`. + * + * @returns The Encrypted User Storage adapter. + */ + #localUserSecretStore(): UkycLocalUserSecretStore { + return { + get: async ( + path: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performGetStorage', + path as `${string}.${string}`, + entropySourceId, + ), + set: async ( + path: string, + value: string, + entropySourceId?: string, + ): Promise => + this.messenger.call( + 'UserStorageController:performSetStorage', + path as `${string}.${string}`, + value, + entropySourceId, + ), + }; + } + /** * Resolves persisted terms + geolocation, and auto-creates a session when * terms are already accepted and an email is available. @@ -905,9 +953,16 @@ export class KycController extends BaseController< } /** - * Runs the SumSub document-verification sub-flow: creates a UKYC session, - * exchanges the wrapped key for an applicant access token, and presents the - * SDK via the injected launcher. + * Runs the SumSub document-verification sub-flow end to end: + * + * 1. requests a per-session wrapping key from the UKYC backend; + * 2. verifies its `jwtChain` against the Fractal JWKS and confirms the + * attested session server public key; + * 3. derives the `data_encryption_key` from the wallet's UKYC + * `local_user_secret` and wraps it for the session server; + * 4. creates the UKYC session (handing over the wrapped key); + * 5. fetches the SumSub applicant access token; and + * 6. presents the SDK via the injected launcher. * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. @@ -939,22 +994,63 @@ export class KycController extends BaseController< }); const jwtToken = MOCK_JWT_TOKEN; - const { sessionId, wrappingPublicKey } = - await this.messenger.call('KycService:createUkycSession', { + + // Establish a per-session X25519 keypair and exchange our public half for + // the server's wrapping key. The private half stays on the device and is + // used to derive the shared secret that seals the data_encryption_key. + const sessionClientPrivateKey = x25519.utils.randomSecretKey(); + const sessionClientPublicKey = x25519.getPublicKey( + sessionClientPrivateKey, + ); + const wrappingKey = await this.messenger.call( + 'KycService:getWrappingKey', + { sessionClientPublicKey: toBase64Url(sessionClientPublicKey) }, + ); + + // Verify the jwtChain against Fractal's JWKS, then confirm the returned + // sessionServerPublicKey matches the value attested inside the verified + // JWT payload before trusting it for key wrapping. + const { keys } = await this.messenger.call('KycService:fetchJwks'); + const jwtChainPayload = verifyJwtChain(keys, wrappingKey.jwtChain); + if ( + jwtChainPayload.sessionServerPublicKeyX !== + wrappingKey.sessionServerPublicKey.x + ) { + throw new Error( + 'sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).', + ); + } + + // Derive the data_encryption_key from the local_user_secret and wrap it + // for the session server. Only the wrapped (encrypted) key ever leaves + // the device. + const localUserSecret = await getOrCreateLocalUserSecret( + this.#localUserSecretStore(), + ); + const { dataEncryptionKey } = deriveClientMaterial(localUserSecret); + const wrappedEncryptionKey = { + sessionId: wrappingKey.id, + ...wrapEncryptionKey( + sessionClientPrivateKey, + wrappingKey.sessionServerPublicKey.x, + dataEncryptionKey, + ), + }; + + const { sessionId, idosSessionId } = await this.messenger.call( + 'KycService:createUkycSession', + { jwtToken, vendorMetadata: { moonPayAccessToken: this.state.accessToken, moonPayUserId: this.state.moonpayCustomerId, }, - }); - // Retain the exchange material so the SDK can refresh its token. The - // session's `wrappingPublicKey` is forwarded opaquely as the request's - // `wrappedUserKey` field (the /wrapped-key endpoint's body is unchanged). - const exchange = { - sessionId, - wrappedUserKey: wrappingPublicKey, - jwtToken, - }; + wrappedEncryptionKey, + }, + ); + + // Retain the session identifiers so the SDK can refresh its token. + const exchange = { sessionId, idosSessionId }; this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'fetchingToken'; @@ -962,7 +1058,7 @@ export class KycController extends BaseController< }); const { applicantAccessToken } = await this.messenger.call( - 'KycService:submitWrappedKey', + 'KycService:fetchApplicantAccessToken', exchange, ); @@ -997,7 +1093,7 @@ export class KycController extends BaseController< ); } const refreshed = await this.messenger.call( - 'KycService:submitWrappedKey', + 'KycService:fetchApplicantAccessToken', exchange, ); return refreshed.applicantAccessToken; diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 844e36faf4c..be1b8fb8800 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -54,10 +54,44 @@ export type KycServiceCheckKycRequiredAction = { }; /** - * Creates a UKYC session for the SumSub document-verification sub-flow. + * Requests a per-session wrapping key from the UKYC backend. + * + * The client sends its ephemeral X25519 public key; the backend responds with + * its session public key (`sessionServerPublicKey`) and a `jwtChain` that + * attests it. The caller must verify `jwtChain` against the Fractal JWKS + * (see {@link KycService.fetchJwks}) before trusting the key to wrap the + * `data_encryption_key`. + * + * @param params - The parameters. + * @param params.sessionClientPublicKey - Our ephemeral X25519 public key + * (base64url). + * @returns The wrapping key id, `jwtChain`, and session server public key. + */ +export type KycServiceGetWrappingKeyAction = { + type: `KycService:getWrappingKey`; + handler: KycService['getWrappingKey']; +}; + +/** + * Fetches the Fractal encryption service JWKS used to verify the `jwtChain` + * returned by {@link KycService.getWrappingKey}. + * + * This is an unauthenticated request to a well-known path on the Fractal + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ +export type KycServiceFetchJwksAction = { + type: `KycService:fetchJwks`; + handler: KycService['fetchJwks']; +}; + +/** + * Creates a UKYC session for the SumSub document-verification sub-flow, + * handing over the wrapped `data_encryption_key`. * * @param params - The session parameters. - * @returns The UKYC session identifiers and wrapped key. + * @returns The UKYC session identifiers. */ export type KycServiceCreateUkycSessionAction = { type: `KycService:createUkycSession`; @@ -65,14 +99,17 @@ export type KycServiceCreateUkycSessionAction = { }; /** - * Exchanges the wrapped user key for a SumSub applicant access token. + * Fetches (or refreshes) the SumSub applicant access token for a UKYC + * session. * - * @param params - The exchange parameters. + * @param params - The parameters. + * @param params.sessionId - The UKYC session id from `createUkycSession`. + * @param params.idosSessionId - The idOS session id from `createUkycSession`. * @returns The applicant access token and status. */ -export type KycServiceSubmitWrappedKeyAction = { - type: `KycService:submitWrappedKey`; - handler: KycService['submitWrappedKey']; +export type KycServiceFetchApplicantAccessTokenAction = { + type: `KycService:fetchApplicantAccessToken`; + handler: KycService['fetchApplicantAccessToken']; }; /** @@ -83,5 +120,7 @@ export type KycServiceMethodActions = | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction + | KycServiceGetWrappingKeyAction + | KycServiceFetchJwksAction | KycServiceCreateUkycSessionAction - | KycServiceSubmitWrappedKeyAction; + | KycServiceFetchApplicantAccessTokenAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 3e388f0e95e..4e6e898b237 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -10,6 +10,7 @@ import type { KycServiceMessenger } from './KycService'; import { KycService } from './KycService'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; +const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; describe('KycService', () => { afterEach(() => { @@ -191,19 +192,89 @@ describe('KycService', () => { }); }); + describe('getWrappingKey', () => { + it('requests a wrapping key and returns the attested server key', async () => { + const response = { + id: 'wk', + jwtChain: 'jwt.chain.sig', + sessionServerPublicKey: { kty: 'OKP', crv: 'X25519', x: 'spk-x' }, + }; + nock(MOCK_API_URL) + .post('/wrapping-key', { sessionClientPublicKey: 'cpk' }) + .reply(200, response); + const { service } = getService(); + + expect( + await service.getWrappingKey({ sessionClientPublicKey: 'cpk' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/wrapping-key').reply(200, { id: 'wk' }); + const { service } = getService(); + + await expect( + service.getWrappingKey({ sessionClientPublicKey: 'cpk' }), + ).rejects.toThrow(/Malformed response received from wrapping-key API/u); + }); + }); + + describe('fetchJwks', () => { + it('fetches the JWKS from the Fractal well-known path', async () => { + const response = { + keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'pub', kid: 'k1' }], + }; + nock(MOCK_FRACTAL_URL) + .get('/.well-known/jwks.json') + .reply(200, response); + const { service } = getService(); + + expect(await service.fetchJwks()).toStrictEqual(response); + }); + + it('throws when no Fractal base URL is configured', async () => { + // Omit the option entirely so the constructor falls back to ''. + const { service } = getService({ fractalEncryptionBaseUrl: null }); + + await expect(service.fetchJwks()).rejects.toThrow( + /fractalEncryptionBaseUrl is not configured/u, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_FRACTAL_URL) + .get('/.well-known/jwks.json') + .reply(200, { keys: [{ kty: 'OKP' }] }); + const { service } = getService(); + + await expect(service.fetchJwks()).rejects.toThrow( + /Malformed response received from JWKS API/u, + ); + }); + }); + describe('createUkycSession', () => { - it('creates a UKYC session', async () => { + const wrappedEncryptionKey = { + sessionId: 'wk', + encryptedKey: 'enc', + nonce: 'nonce', + }; + + it('creates a UKYC session and forwards the wrapped key', async () => { const response = { sessionId: 'sid', - wrappingPublicKey: 'wpk', + idosSessionId: 'iss', }; - nock(MOCK_API_URL).post('/sessions').reply(200, response); + nock(MOCK_API_URL) + .post('/sessions', (body) => body.wrappedEncryptionKey !== undefined) + .reply(200, response); const { service } = getService(); expect( await service.createUkycSession({ jwtToken: 'jwt', vendorMetadata: { foo: 'bar' }, + wrappedEncryptionKey, }), ).toStrictEqual(response); }); @@ -213,22 +284,27 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.createUkycSession({ jwtToken: 'jwt', vendorMetadata: {} }), + service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: {}, + wrappedEncryptionKey, + }), ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); }); }); - describe('submitWrappedKey', () => { - it('exchanges the wrapped key for an applicant access token', async () => { + describe('fetchApplicantAccessToken', () => { + it('fetches the applicant access token for a session', async () => { const response = { status: 'ok', applicantAccessToken: 'aat' }; - nock(MOCK_API_URL).post('/sessions/sid/wrapped-key').reply(200, response); + nock(MOCK_API_URL) + .post('/sessions/sid/wrapped-key', { idosSessionId: 'iss' }) + .reply(200, response); const { service } = getService(); expect( - await service.submitWrappedKey({ + await service.fetchApplicantAccessToken({ sessionId: 'sid', - wrappedUserKey: 'wuk', - jwtToken: 'jwt', + idosSessionId: 'iss', }), ).toStrictEqual(response); }); @@ -240,10 +316,9 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.submitWrappedKey({ + service.fetchApplicantAccessToken({ sessionId: 'sid', - wrappedUserKey: 'wuk', - jwtToken: 'jwt', + idosSessionId: 'iss', }), ).rejects.toThrow(/Malformed response received from wrapped-key API/u); }); @@ -298,6 +373,8 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - When provided, overrides the env-derived base URL. + * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the + * option so the service falls back to an empty string. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -305,11 +382,15 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl, + // `null` means "omit the option entirely" (exercises the constructor's + // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. + fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; + fractalEncryptionBaseUrl?: string | null; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -343,6 +424,7 @@ function getService({ fetch, messenger, env: 'development', + ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(baseUrl ? { baseUrl } : {}), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 2b854e9faf3..f12abe00f42 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -19,6 +19,7 @@ import { import { alpha2ToAlpha3 } from './countryCodes'; import type { KycServiceMethodActions } from './KycService-method-action-types'; import type { KycDisclaimer } from './types'; +import { UKYC_JWKS_PATH } from './ukyc'; // === GENERAL === @@ -44,8 +45,10 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchDisclaimers', 'createSession', 'checkKycRequired', + 'getWrappingKey', + 'fetchJwks', 'createUkycSession', - 'submitWrappedKey', + 'fetchApplicantAccessToken', ] as const; /** @@ -93,6 +96,13 @@ export type KycServiceOptions = { * targeting a local or staging KYC API. */ baseUrl?: string; + /** + * Base URL of the Fractal encryption service, from which the JWKS used to + * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is + * fetched. Required to run the wrapped-key exchange in + * {@link KycService.fetchJwks}. + */ + fractalEncryptionBaseUrl?: string; policyOptions?: CreateServicePolicyOptions; }; @@ -111,17 +121,45 @@ const CreateSessionResponseStruct = type({ sessionToken: string() }); // this to `kycRequired` for consumers (see `checkKycRequired`). const KycRequiredResponseStruct = type({ required: boolean() }); +// The session server's X25519 public key, in JWK-like form, returned by +// `/wrapping-key`. `x` is the base64url public key used to wrap the user key. +const SessionServerPublicKeyStruct = type({ + kty: string(), + crv: string(), + x: string(), +}); + +const WrappingKeyResponseStruct = type({ + id: string(), + jwtChain: string(), + sessionServerPublicKey: SessionServerPublicKeyStruct, +}); +export type WrappingKeyResponse = Infer; + +// A single Ed25519 (OKP) JWK. `type` (not `object`) keeps optional/extra JWK +// fields (`use`, `alg`) from failing validation. +const JwkStruct = type({ + kty: string(), + crv: string(), + x: string(), + kid: string(), +}); +const JwksResponseStruct = type({ keys: array(JwkStruct) }); +export type JwksResponse = Infer; + const UkycSessionResponseStruct = type({ sessionId: string(), - wrappingPublicKey: string(), + idosSessionId: string(), }); export type UkycSessionResponse = Infer; -const WrappedKeyResponseStruct = type({ +const ApplicantAccessTokenResponseStruct = type({ status: string(), applicantAccessToken: string(), }); -export type WrappedKeyResponse = Infer; +export type ApplicantAccessTokenResponse = Infer< + typeof ApplicantAccessTokenResponseStruct +>; // === PARAM TYPES === @@ -137,15 +175,30 @@ export type CheckKycRequiredParams = { capabilities?: { product: string }[]; }; +export type GetWrappingKeyParams = { + sessionClientPublicKey: string; +}; + +/** + * The wrapped `data_encryption_key` sent to the UKYC backend when creating a + * session. `encryptedKey` and `nonce` are produced by `wrapEncryptionKey`; + * `sessionId` is the wrapping key id returned by `getWrappingKey`. + */ +export type WrappedEncryptionKey = { + sessionId: string; + encryptedKey: string; + nonce: string; +}; + export type CreateUkycSessionParams = { jwtToken: string; vendorMetadata: Record; + wrappedEncryptionKey: WrappedEncryptionKey; }; -export type SubmitWrappedKeyParams = { +export type FetchApplicantAccessTokenParams = { sessionId: string; - wrappedUserKey: string; - jwtToken: string; + idosSessionId: string; }; // === SERVICE DEFINITION === @@ -165,6 +218,8 @@ export class KycService { readonly #baseUrl: string; + readonly #fractalEncryptionBaseUrl: string; + readonly #policy: ServicePolicy; /** @@ -175,6 +230,9 @@ export class KycService { * @param options.fetch - A function used to make HTTP requests. * @param options.env - The environment; determines the base URL. * @param options.baseUrl - Overrides the base URL derived from `env`. + * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal + * encryption service, from which the JWKS used to verify the wrapping-key + * `jwtChain` is fetched. * @param options.policyOptions - Options for the request service policy. */ constructor({ @@ -182,12 +240,14 @@ export class KycService { fetch: fetchFunction, env, baseUrl, + fractalEncryptionBaseUrl, policyOptions, }: KycServiceOptions) { this.name = serviceName; this.#messenger = messenger; this.#fetch = fetchFunction; this.#baseUrl = baseUrl ?? KYC_API_URLS[env]; + this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.#policy = createServicePolicy(policyOptions ?? {}); this.#messenger.registerMethodActionHandlers( this, @@ -300,10 +360,66 @@ export class KycService { } /** - * Creates a UKYC session for the SumSub document-verification sub-flow. + * Requests a per-session wrapping key from the UKYC backend. + * + * The client sends its ephemeral X25519 public key; the backend responds with + * its session public key (`sessionServerPublicKey`) and a `jwtChain` that + * attests it. The caller must verify `jwtChain` against the Fractal JWKS + * (see {@link KycService.fetchJwks}) before trusting the key to wrap the + * `data_encryption_key`. + * + * @param params - The parameters. + * @param params.sessionClientPublicKey - Our ephemeral X25519 public key + * (base64url). + * @returns The wrapping key id, `jwtChain`, and session server public key. + */ + async getWrappingKey( + params: GetWrappingKeyParams, + ): Promise { + const url = new URL('/wrapping-key', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + sessionClientPublicKey: params.sessionClientPublicKey, + }), + }); + return this.#validateResponse( + data, + WrappingKeyResponseStruct, + 'wrapping-key', + ); + } + + /** + * Fetches the Fractal encryption service JWKS used to verify the `jwtChain` + * returned by {@link KycService.getWrappingKey}. + * + * This is an unauthenticated request to a well-known path on the Fractal + * host, distinct from the UKYC base URL. + * + * @returns The JWKS keys. + */ + async fetchJwks(): Promise { + if (!this.#fractalEncryptionBaseUrl) { + throw new Error( + 'KycService: fractalEncryptionBaseUrl is not configured; cannot fetch JWKS to verify the wrapping key.', + ); + } + const url = new URL(UKYC_JWKS_PATH, this.#fractalEncryptionBaseUrl); + const data = await this.#request( + url, + { method: 'GET' }, + { authenticated: false }, + ); + return this.#validateResponse(data, JwksResponseStruct, 'JWKS'); + } + + /** + * Creates a UKYC session for the SumSub document-verification sub-flow, + * handing over the wrapped `data_encryption_key`. * * @param params - The session parameters. - * @returns The UKYC session identifiers and wrapped key. + * @returns The UKYC session identifiers. */ async createUkycSession( params: CreateUkycSessionParams, @@ -316,6 +432,7 @@ export class KycService { vendorUserId: 'mockedId', jwtToken: params.jwtToken, vendorMetadata: params.vendorMetadata, + wrappedEncryptionKey: params.wrappedEncryptionKey, }), }); return this.#validateResponse( @@ -326,29 +443,28 @@ export class KycService { } /** - * Exchanges the wrapped user key for a SumSub applicant access token. + * Fetches (or refreshes) the SumSub applicant access token for a UKYC + * session. * - * @param params - The exchange parameters. + * @param params - The parameters. + * @param params.sessionId - The UKYC session id from `createUkycSession`. + * @param params.idosSessionId - The idOS session id from `createUkycSession`. * @returns The applicant access token and status. */ - async submitWrappedKey( - params: SubmitWrappedKeyParams, - ): Promise { + async fetchApplicantAccessToken( + params: FetchApplicantAccessTokenParams, + ): Promise { const url = new URL( `/sessions/${encodeURIComponent(params.sessionId)}/wrapped-key`, this.#baseUrl, ); const data = await this.#request(url, { method: 'POST', - body: JSON.stringify({ - wrappedUserKey: params.wrappedUserKey, - jwtToken: params.jwtToken, - sessionId: params.sessionId, - }), + body: JSON.stringify({ idosSessionId: params.idosSessionId }), }); return this.#validateResponse( data, - WrappedKeyResponseStruct, + ApplicantAccessTokenResponseStruct, 'wrapped-key', ); } @@ -379,7 +495,10 @@ export class KycService { const detail = error instanceof StructError ? `${error.message} (received: ${JSON.stringify(data)})` - : String(error); + : // `assert` only ever throws `StructError` for the plain structs used + // here, so this is a defensive fallback that is not exercised. + /* istanbul ignore next */ + String(error); throw new Error( `Malformed response received from ${apiName} API: ${detail}`, ); @@ -387,30 +506,47 @@ export class KycService { } /** - * Performs an authenticated JSON request wrapped in the service policy. + * Performs a JSON request wrapped in the service policy. + * + * Requests are authenticated with the wallet bearer token by default; pass + * `{ authenticated: false }` for calls to services that do not expect it + * (e.g. the Fractal JWKS endpoint). * * @param url - The request URL. * @param init - The request init (method, body). + * @param options - Request options. + * @param options.authenticated - Whether to attach the bearer token. Defaults + * to `true`. * @returns The parsed JSON response. */ - async #request(url: URL, init: RequestInit): Promise { - const bearerToken = await this.#messenger.call( - 'AuthenticationController:getBearerToken', - ); - assert(bearerToken, string()); - if (!bearerToken) { - throw new Error( - 'Unable to obtain an authentication bearer token — is the wallet signed in?', + async #request( + url: URL, + init: RequestInit, + options: { authenticated?: boolean } = {}, + ): Promise { + const { authenticated = true } = options; + + const headers: Record = { + 'Content-Type': 'application/json', + }; + + if (authenticated) { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token — is the wallet signed in?', + ); + } + headers.Authorization = `Bearer ${bearerToken}`; } const response = await this.#policy.execute(async () => { const localResponse = await this.#fetch(url.toString(), { ...init, - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${bearerToken}`, - }, + headers, }); if (!localResponse.ok) { throw new HttpError( diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 816afb882b8..5e57f78561d 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -29,25 +29,31 @@ export type { export { KycService, serviceName } from './KycService'; export type { + ApplicantAccessTokenResponse, CheckKycRequiredParams, CreateSessionParams, CreateUkycSessionParams, + FetchApplicantAccessTokenParams, + GetWrappingKeyParams, + JwksResponse, KycServiceActions, KycServiceEnvironment, KycServiceEvents, KycServiceMessenger, KycServiceOptions, - SubmitWrappedKeyParams, UkycSessionResponse, - WrappedKeyResponse, + WrappedEncryptionKey, + WrappingKeyResponse, } from './KycService'; export type { KycServiceCheckKycRequiredAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, + KycServiceFetchApplicantAccessTokenAction, KycServiceFetchDisclaimersAction, + KycServiceFetchJwksAction, KycServiceGetGeoCountryAction, - KycServiceSubmitWrappedKeyAction, + KycServiceGetWrappingKeyAction, } from './KycService-method-action-types'; export { @@ -74,3 +80,41 @@ export type { KycSumSubStatus, KycVendor, } from './types'; + +export { + base64UrlToBytes, + buildWrappedRelayPayload, + canonicalizeJson, + deriveClientMaterial, + encodeClientMaterial, + encodeStorageAccessTokenForHeader, + getOrCreateLocalUserSecret, + hasLocalUserSecret, + loadLocalUserSecret, + signStorageAccessToken, + toBase64Url, + UKYC_DERIVED_KEY_SIZES, + UKYC_JWKS_PATH, + UKYC_KDF_INFO, + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, + verifyJwtChain, + wrapEncryptionKey, + wrapUserKey, +} from './ukyc'; +export type { + EncodedUkycClientMaterial, + Jwk, + JwtChainPayload, + SignStorageAccessTokenParams, + UkycClientMaterial, + UkycLocalUserSecretStore, + UkycStorageAccessToken, + UkycStorageAccessTokenPayload, + UkycStorageOperation, + UkycTokenPresenter, + UkycWrappedRelayPayload, + WrappedEncryptionKeyParts, +} from './ukyc'; diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts new file mode 100644 index 00000000000..d50850dcf65 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/constants.ts @@ -0,0 +1,59 @@ +/** + * Constants for the UKYC client-derived key material and storage-authorization + * layer. See the architecture doc, section "Client-Derived Material". + */ + +/** + * Fully-qualified key path for the `local_user_secret` in Encrypted User + * Storage. + */ +export const UKYC_LOCAL_USER_SECRET_PATH = `ukyc.local_user_secret` as const; + +/** + * Size of the `local_user_secret` in bytes. 32 bytes (256 bits) provides high + * entropy and matches the input length expected by the HKDF-SHA256 derivations + * below. + */ +export const UKYC_LOCAL_USER_SECRET_SIZE_BYTES = 32; + +/** + * Byte length of each value derived from `local_user_secret`. + * + * `signingKey` is 32 bytes because it is used directly as the Ed25519 + * private key (for Ed25519 the 32-byte seed *is* the private key). + */ +export const UKYC_DERIVED_KEY_SIZES = { + storageId: 32, + dataEncryptionKey: 32, + signingKey: 32, + relayTunnelKey: 32, +} as const; + +/** + * HKDF `info` labels providing domain separation between the values derived + * from `local_user_secret`. + */ +export const UKYC_KDF_INFO = { + storageId: 'metamask.ukyc.storage.v1.storage_id', + dataEncryptionKey: 'metamask.ukyc.storage.v1.data_encryption_key', + signingKey: 'metamask.ukyc.storage.v1.signing_key', + relayTunnelKey: 'metamask.ukyc.storage.v1.relay_tunnel_key', +} as const; + +/** + * Version bound into every `storage_access_token` payload. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_VERSION = 1; + +/** + * Audience bound into every `storage_access_token` payload, scoping the + * capability to the UKYC user-storage service. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE = + 'metamask:user-storage:ukyc' as const; + +/** + * Standard well-known path where the Fractal encryption service publishes its + * JWKS (the Ed25519 public keys used to sign the `jwtChain`). + */ +export const UKYC_JWKS_PATH = '/.well-known/jwks.json'; diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts new file mode 100644 index 00000000000..a5506311e0e --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts @@ -0,0 +1,109 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_DERIVED_KEY_SIZES, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial'; + +const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 42, +); +const OTHER_LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(43); + +describe('UKYC deriveClientMaterial', () => { + it('derives each value at the documented length', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.storageId).toHaveLength(UKYC_DERIVED_KEY_SIZES.storageId); + expect(material.dataEncryptionKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + expect(material.signingKey).toHaveLength(UKYC_DERIVED_KEY_SIZES.signingKey); + expect(material.relayTunnelKey).toHaveLength( + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + // Ed25519 public keys are 32 bytes. + expect(material.signingPublicKey).toHaveLength(32); + }); + + it('is deterministic for the same local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(a).toStrictEqual(b); + }); + + it('produces different material for a different local_user_secret', () => { + const a = deriveClientMaterial(LOCAL_USER_SECRET); + const b = deriveClientMaterial(OTHER_LOCAL_USER_SECRET); + + expect(areUint8ArraysEqual(a.storageId, b.storageId)).toBe(false); + expect(areUint8ArraysEqual(a.dataEncryptionKey, b.dataEncryptionKey)).toBe( + false, + ); + expect(areUint8ArraysEqual(a.signingKey, b.signingKey)).toBe(false); + expect(areUint8ArraysEqual(a.relayTunnelKey, b.relayTunnelKey)).toBe(false); + }); + + it('domain-separates the derived values from one another', () => { + const { storageId, dataEncryptionKey, signingKey, relayTunnelKey } = + deriveClientMaterial(LOCAL_USER_SECRET); + const values = [storageId, dataEncryptionKey, signingKey, relayTunnelKey]; + + for (let i = 0; i < values.length; i++) { + for (let j = i + 1; j < values.length; j++) { + expect(areUint8ArraysEqual(values[i], values[j])).toBe(false); + } + } + }); + + it('derives a signing public key that matches the signing key', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + expect(material.signingPublicKey).toStrictEqual( + ed25519.getPublicKey(material.signingKey), + ); + }); + + it('produces a working Ed25519 keypair for storage authorization', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + const message = new TextEncoder().encode('storage-authorization-payload'); + + const signature = ed25519.sign(message, material.signingKey); + + expect(ed25519.verify(signature, message, material.signingPublicKey)).toBe( + true, + ); + }); +}); + +describe('UKYC encodeClientMaterial', () => { + it('encodes storage_id and signing public key as unpadded base64url', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(encoded.storageId).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.signingPublicKey).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encoded.storageId).not.toContain('='); + expect(encoded.signingPublicKey).not.toContain('='); + }); + + it('omits secret material from the encoded output', () => { + const material = deriveClientMaterial(LOCAL_USER_SECRET); + + const encoded = encodeClientMaterial(material); + + expect(Object.keys(encoded).sort()).toStrictEqual([ + 'signingPublicKey', + 'storageId', + ]); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts new file mode 100644 index 00000000000..e796d1125e7 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -0,0 +1,128 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { stringToBytes } from '@metamask/utils'; + +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants'; +import { toBase64Url } from './encoding'; + +/** + * Derives UKYC client material from the root `local_user_secret` using + * HKDF-SHA256 with domain-separated `info` labels — see the architecture doc, + * section "Client-Derived Material". + */ + +/** + * The set of values derived from `local_user_secret`. + */ +export type UkycClientMaterial = { + /** Opaque lookup key for the encrypted KYC object. */ + storageId: Uint8Array; + /** Symmetric key that encrypts `encrypted_kyc_data` (or wraps per-blob keys). */ + dataEncryptionKey: Uint8Array; + /** + * Ed25519 private key used to sign `storage_access_token` capabilities. For + * Ed25519 the 32-byte HKDF output *is* the private key. The private half + * never leaves the device. + */ + signingKey: Uint8Array; + /** Public half of `signingKey`, registered with the object on first write. */ + signingPublicKey: Uint8Array; + /** Key for establishing/authenticating the encrypted tunnel to idOS, if needed. */ + relayTunnelKey: Uint8Array; +}; + +/** + * The same material with byte fields base64url-encoded, matching the wire + * shapes in the architecture doc (`storage_id` as an opaque id, public key as a + * "base64url public key"). Secret material is intentionally omitted. + */ +export type EncodedUkycClientMaterial = { + storageId: string; + signingPublicKey: string; +}; + +/** + * Derives a single labeled value from `local_user_secret`. + * + * No salt is used: `local_user_secret` is already a high-entropy + * uniformly-random secret, so per-output domain separation comes entirely from + * the `info` label. + * + * @param localUserSecret - The root `local_user_secret` bytes. + * @param info - Domain-separation label for this output. + * @param length - Desired output length in bytes. + * @returns The derived bytes. + */ +function deriveLabeled( + localUserSecret: Uint8Array, + info: string, + length: number, +): Uint8Array { + return hkdf(sha256, localUserSecret, undefined, stringToBytes(info), length); +} + +/** + * Derives all UKYC client material from the root `local_user_secret`. + * + * This is a pure function of `local_user_secret`: the same input always yields + * the same outputs, which is what makes `storage_id` and `signing_key` stable + * across sessions and devices. + * + * @param localUserSecret - The `local_user_secret` produced by + * `getOrCreateLocalUserSecret`. + * @returns The derived {@link UkycClientMaterial}. + */ +export function deriveClientMaterial( + localUserSecret: Uint8Array, +): UkycClientMaterial { + const storageId = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.storageId, + UKYC_DERIVED_KEY_SIZES.storageId, + ); + + const dataEncryptionKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.dataEncryptionKey, + UKYC_DERIVED_KEY_SIZES.dataEncryptionKey, + ); + + const signingKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.signingKey, + UKYC_DERIVED_KEY_SIZES.signingKey, + ); + + const relayTunnelKey = deriveLabeled( + localUserSecret, + UKYC_KDF_INFO.relayTunnelKey, + UKYC_DERIVED_KEY_SIZES.relayTunnelKey, + ); + + const signingPublicKey = ed25519.getPublicKey(signingKey); + + return { + storageId, + dataEncryptionKey, + signingKey, + signingPublicKey, + relayTunnelKey, + }; +} + +/** + * Encodes the non-secret client material into the base64url wire shapes used by + * the UKYC storage API (`storage_id` and `signing_public_key`). + * + * @param material - The derived client material. + * @returns The base64url-encoded, non-secret fields. + */ +export function encodeClientMaterial( + material: UkycClientMaterial, +): EncodedUkycClientMaterial { + return { + storageId: toBase64Url(material.storageId), + signingPublicKey: toBase64Url(material.signingPublicKey), + }; +} diff --git a/packages/kyc-controller/src/ukyc/encoding.test.ts b/packages/kyc-controller/src/ukyc/encoding.test.ts new file mode 100644 index 00000000000..63949973cd3 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/encoding.test.ts @@ -0,0 +1,32 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; + +import { base64UrlToBytes, toBase64Url } from './encoding'; + +describe('UKYC encoding', () => { + describe('toBase64Url', () => { + it('produces unpadded, url-safe base64', () => { + // 0xFB 0xFF encodes to "+/8=" in standard base64, exercising both the + // `+`->`-`, `/`->`_`, and padding-stripping substitutions. + const encoded = toBase64Url(new Uint8Array([0xfb, 0xff])); + + expect(encoded).toBe('-_8'); + expect(encoded).not.toContain('='); + }); + }); + + describe('base64UrlToBytes', () => { + it('round-trips arbitrary bytes through toBase64Url', () => { + const bytes = new Uint8Array([0x00, 0x01, 0xfb, 0xff, 0x10, 0x2a, 0x7f]); + + const roundTripped = base64UrlToBytes(toBase64Url(bytes)); + + expect(areUint8ArraysEqual(roundTripped, bytes)).toBe(true); + }); + + it('decodes an already-padded standard base64url string', () => { + const bytes = new Uint8Array([0xfb, 0xff]); + + expect(areUint8ArraysEqual(base64UrlToBytes('-_8='), bytes)).toBe(true); + }); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/encoding.ts b/packages/kyc-controller/src/ukyc/encoding.ts new file mode 100644 index 00000000000..116edc83ebf --- /dev/null +++ b/packages/kyc-controller/src/ukyc/encoding.ts @@ -0,0 +1,40 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +/** + * Encoding helpers shared across the UKYC client-material and + * storage-authorization modules. + * + * These are platform-agnostic: they rely on `@metamask/utils` rather than + * `Buffer` / `atob`, so they run unchanged on mobile, extension, and web. + */ + +/** + * Encodes bytes as unpadded base64url (RFC 4648 §5). This is the wire shape + * used for `storage_id`, `signing_public_key`, and Ed25519 signatures in the + * UKYC storage API. + * + * @param bytes - The bytes to encode. + * @returns The base64url string without `=` padding. + */ +export function toBase64Url(bytes: Uint8Array): string { + return bytesToBase64(bytes) + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); +} + +/** + * Decodes an unpadded (or padded) base64url string back to bytes. Inverse of + * {@link toBase64Url}. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +export function base64UrlToBytes(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} diff --git a/packages/kyc-controller/src/ukyc/index.ts b/packages/kyc-controller/src/ukyc/index.ts new file mode 100644 index 00000000000..825488c1955 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/index.ts @@ -0,0 +1,43 @@ +export { + UKYC_DERIVED_KEY_SIZES, + UKYC_JWKS_PATH, + UKYC_KDF_INFO, + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants'; +export { base64UrlToBytes, toBase64Url } from './encoding'; +export { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial'; +export type { + EncodedUkycClientMaterial, + UkycClientMaterial, +} from './deriveClientMaterial'; +export { verifyJwtChain } from './jwtChain'; +export type { Jwk, JwtChainPayload } from './jwtChain'; +export { + getOrCreateLocalUserSecret, + hasLocalUserSecret, + loadLocalUserSecret, +} from './localUserSecret'; +export type { UkycLocalUserSecretStore } from './localUserSecret'; +export { + canonicalizeJson, + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken'; +export type { + SignStorageAccessTokenParams, + UkycStorageAccessToken, + UkycStorageAccessTokenPayload, + UkycStorageOperation, + UkycTokenPresenter, +} from './storageAccessToken'; +export { wrapEncryptionKey } from './wrapEncryptionKey'; +export type { WrappedEncryptionKeyParts } from './wrapEncryptionKey'; +export { wrapUserKey } from './wrapUserKey'; +export { buildWrappedRelayPayload } from './wrappedRelayPayload'; +export type { UkycWrappedRelayPayload } from './wrappedRelayPayload'; diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts new file mode 100644 index 00000000000..fd6244e7b65 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts @@ -0,0 +1,100 @@ +import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { toBase64Url } from './encoding'; +import type { Jwk } from './jwtChain'; +import { verifyJwtChain } from './jwtChain'; + +const KID = 'key-1'; +const PAYLOAD = { sessionServerPublicKeyX: 'spk-x', nonce: 'nonce-1' }; + +const SIGNING_PRIVATE_KEY = ed25519.utils.randomSecretKey(); +const SIGNING_PUBLIC_KEY = ed25519.getPublicKey(SIGNING_PRIVATE_KEY); + +const JWK: Jwk = { + kty: 'OKP', + crv: 'Ed25519', + x: toBase64Url(SIGNING_PUBLIC_KEY), + kid: KID, +}; + +/** + * Builds a compact EdDSA JWT signed with the module's signing key. + * + * @param options - Overrides. + * @param options.header - The protected header (defaults to a valid EdDSA one). + * @param options.payload - The payload (defaults to {@link PAYLOAD}). + * @param options.privateKey - The signing key (defaults to the module key). + * @param options.tamper - When true, corrupts the signature. + * @returns The compact-serialized JWT. + */ +function buildJwt({ + header = { alg: 'EdDSA', kid: KID }, + payload = PAYLOAD, + privateKey = SIGNING_PRIVATE_KEY, + tamper = false, +}: { + header?: Record; + payload?: Record; + privateKey?: Uint8Array; + tamper?: boolean; +} = {}): string { + const headerSegment = toBase64Url(stringToBytes(JSON.stringify(header))); + const payloadSegment = toBase64Url(stringToBytes(JSON.stringify(payload))); + const signature = ed25519.sign( + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + privateKey, + ); + if (tamper) { + signature[0] = signature[0] === 0 ? 1 : 0; + } + return `${headerSegment}.${payloadSegment}.${toBase64Url(signature)}`; +} + +describe('UKYC verifyJwtChain', () => { + it('returns the payload for a validly-signed jwtChain', () => { + expect(verifyJwtChain([JWK], buildJwt())).toStrictEqual(PAYLOAD); + }); + + it('rejects a jwtChain that is not three segments', () => { + expect(() => verifyJwtChain([JWK], 'only.two')).toThrow( + 'not a well-formed JWT', + ); + }); + + it('rejects a non-EdDSA algorithm', () => { + const jwt = buildJwt({ header: { alg: 'RS256', kid: KID } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('expected EdDSA'); + }); + + it('rejects when no JWKS key matches the kid', () => { + const jwt = buildJwt({ header: { alg: 'EdDSA', kid: 'other' } }); + + expect(() => verifyJwtChain([JWK], jwt)).toThrow('no JWKS key matches'); + }); + + it('rejects a JWKS key that is not an Ed25519 OKP key', () => { + const badJwk: Jwk = { ...JWK, crv: 'X25519' }; + + expect(() => verifyJwtChain([badJwk], buildJwt())).toThrow( + 'is not an Ed25519 OKP key', + ); + }); + + it('rejects a tampered signature', () => { + expect(() => verifyJwtChain([JWK], buildJwt({ tamper: true }))).toThrow( + 'signature verification failed', + ); + }); + + it('rejects a malformed (non-JSON) header segment', () => { + const jwt = `not-json.${toBase64Url( + stringToBytes(JSON.stringify(PAYLOAD)), + )}.sig`; + + expect(() => verifyJwtChain([JWK], jwt)).toThrow( + 'failed to decode jwtChain header', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts new file mode 100644 index 00000000000..5b1ff269dc3 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -0,0 +1,114 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { bytesToString } from '@metamask/utils'; + +import { base64UrlToBytes } from './encoding'; + +/** + * Verifies the `jwtChain` returned by the Fractal encryption service against + * its published JWKS. + * + * The signature check is done with `@noble/curves` (rather than WebCrypto + * `subtle`) because not every MetaMask runtime exposes a `subtle` + * implementation for Ed25519; JWT parsing is a plain base64url/JSON decode, so + * no `jose` dependency is required. + */ + +/** + * A single Ed25519 (OKP) JSON Web Key from the Fractal JWKS. + */ +export type Jwk = { + kty: string; + crv: string; + x: string; + kid: string; + use?: string; + alg?: string; +}; + +/** + * The verified `jwtChain` payload. `sessionServerPublicKeyX` attests the + * server's X25519 public key so the client can confirm the value returned + * out-of-band by `getWrappingKey` was not tampered with. + */ +export type JwtChainPayload = { + sessionServerPublicKeyX: string; + nonce: string; +}; + +/** + * The protected header of a compact JWT. + */ +type JwtHeader = { + alg?: string; + kid?: string; +}; + +/** + * Decodes a base64url JWT segment into a parsed JSON object. + * + * @param segment - The base64url-encoded segment. + * @param label - Human-readable segment name for error messages. + * @returns The parsed JSON object. + */ +function decodeJsonSegment(segment: string, label: string): Type { + try { + return JSON.parse(bytesToString(base64UrlToBytes(segment))) as Type; + } catch (error) { + throw new Error(`UKYC: failed to decode jwtChain ${label}: ${String(error)}`); + } +} + +/** + * Verifies `jwtChain` against `keys`: matches the JWT header `kid` to a + * published Ed25519 signing key and checks the EdDSA signature over the + * `header.payload` input. Returns the decoded, verified payload. + * + * @param keys - The JWKS keys published by the Fractal encryption service. + * @param jwtChain - The compact-serialized EdDSA JWT from `getWrappingKey`. + * @returns The verified JWT payload. + */ +export function verifyJwtChain( + keys: Jwk[], + jwtChain: string, +): JwtChainPayload { + const [headerSegment, payloadSegment, signatureSegment] = jwtChain.split('.'); + if (!headerSegment || !payloadSegment || !signatureSegment) { + throw new Error( + 'UKYC: jwtChain is not a well-formed JWT (expected 3 segments).', + ); + } + + const header = decodeJsonSegment(headerSegment, 'header'); + if (header.alg !== 'EdDSA') { + throw new Error( + `UKYC: unsupported jwtChain alg "${String( + header.alg, + )}" (expected EdDSA).`, + ); + } + + const jwk = keys.find((key) => key.kid === header.kid); + if (!jwk) { + throw new Error( + `UKYC: no JWKS key matches jwtChain kid "${String(header.kid)}".`, + ); + } + if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519') { + throw new Error( + `UKYC: JWKS key ${jwk.kid} is not an Ed25519 OKP key (kty=${jwk.kty}, crv=${jwk.crv}).`, + ); + } + + const isValid = ed25519.verify( + base64UrlToBytes(signatureSegment), + new TextEncoder().encode(`${headerSegment}.${payloadSegment}`), + base64UrlToBytes(jwk.x), + ); + if (!isValid) { + throw new Error( + 'UKYC: jwtChain signature verification failed against JWKS.', + ); + } + + return decodeJsonSegment(payloadSegment, 'payload'); +} diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts new file mode 100644 index 00000000000..51f5e50f7f8 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts @@ -0,0 +1,148 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants'; +import type { UkycLocalUserSecretStore } from './localUserSecret'; +import { + getOrCreateLocalUserSecret, + hasLocalUserSecret, + loadLocalUserSecret, +} from './localUserSecret'; + +const SECRET_BYTES = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(7); +const SECRET_BASE64 = bytesToBase64(SECRET_BYTES); + +/** + * Builds a stateful in-memory store adapter backed by a single value. + * + * @param initial - The initial stored base64 value. + * @returns The store plus jest spies for `get` / `set`. + */ +function makeStore(initial: string | null = null): { + store: UkycLocalUserSecretStore; + get: jest.Mock; + set: jest.Mock; +} { + let value = initial; + const get = jest.fn(async () => value); + const set = jest.fn(async (_path: string, next: string) => { + value = next; + }); + return { store: { get, set }, get, set }; +} + +describe('UKYC localUserSecret', () => { + describe('loadLocalUserSecret', () => { + it('returns null when no local_user_secret is stored', async () => { + const { store, get } = makeStore(null); + + expect(await loadLocalUserSecret(store)).toBeNull(); + expect(get).toHaveBeenCalledWith(UKYC_LOCAL_USER_SECRET_PATH, undefined); + }); + + it('decodes and returns the stored local_user_secret', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await loadLocalUserSecret(store)).toStrictEqual(SECRET_BYTES); + }); + + it('forwards the entropy source id', async () => { + const { store, get } = makeStore(SECRET_BASE64); + + await loadLocalUserSecret(store, 'entropy-1'); + + expect(get).toHaveBeenCalledWith( + UKYC_LOCAL_USER_SECRET_PATH, + 'entropy-1', + ); + }); + + it('throws when the stored local_user_secret has an unexpected length', async () => { + const { store } = makeStore(bytesToBase64(new Uint8Array(16))); + + await expect(loadLocalUserSecret(store)).rejects.toThrow( + 'unexpected length', + ); + }); + }); + + describe('getOrCreateLocalUserSecret', () => { + it('returns the existing local_user_secret without generating a new one', async () => { + const { store, set } = makeStore(SECRET_BASE64); + + expect(await getOrCreateLocalUserSecret(store)).toStrictEqual( + SECRET_BYTES, + ); + expect(set).not.toHaveBeenCalled(); + }); + + it('generates and persists a new local_user_secret on first enrollment', async () => { + const { store, set } = makeStore(null); + + const result = await getOrCreateLocalUserSecret(store); + + expect(set).toHaveBeenCalledTimes(1); + const [path, persisted] = set.mock.calls[0]; + expect(path).toBe(UKYC_LOCAL_USER_SECRET_PATH); + // The persisted value round-trips to the returned bytes. + expect(result).toStrictEqual(base64ToBytes(persisted)); + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + + it('converges on a competing value that won the write race', async () => { + const competing = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 9, + ); + // First read (existence check) misses; the re-read after our write sees a + // value another writer landed first. + const get = jest + .fn() + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(bytesToBase64(competing)); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toStrictEqual(competing); + }); + + it('deduplicates concurrent create calls into a single generation', async () => { + const { store, set } = makeStore(null); + + const [a, b] = await Promise.all([ + getOrCreateLocalUserSecret(store), + getOrCreateLocalUserSecret(store), + ]); + + expect(a).toStrictEqual(b); + expect(set).toHaveBeenCalledTimes(1); + }); + + it('falls back to the generated secret if the re-read returns nothing', async () => { + // `get` always misses, even after the write, so the helper falls back to + // the value it just generated. + const get = jest.fn().mockResolvedValue(null); + const set = jest.fn().mockResolvedValue(undefined); + + const result = await getOrCreateLocalUserSecret({ get, set }); + + expect(result).toHaveLength(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + }); + }); + + describe('hasLocalUserSecret', () => { + it('returns true when a local_user_secret exists', async () => { + const { store } = makeStore(SECRET_BASE64); + + expect(await hasLocalUserSecret(store)).toBe(true); + }); + + it('returns false when no local_user_secret exists', async () => { + const { store } = makeStore(null); + + expect(await hasLocalUserSecret(store)).toBe(false); + }); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts new file mode 100644 index 00000000000..e7a2db7c5b3 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts @@ -0,0 +1,165 @@ +import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; +import { randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_LOCAL_USER_SECRET_PATH, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants'; + +/** + * Orchestrates creation and loading of the UKYC `local_user_secret`. + * + * The `local_user_secret` is the root secret for all UKYC client-derived + * material. It is generated once, on first enrollment, and persisted to + * MetaMask Encrypted User Storage. It is never transmitted off the device, not + * even to the idOS Relay. Every subsequent value (`storage_id`, + * `data_encryption_key`, `signing_key`, `relay_tunnel_key`) is derived from it + * via HKDF — see `deriveClientMaterial`. + * + * This module is platform-agnostic: the Encrypted User Storage backing is + * injected as a {@link UkycLocalUserSecretStore} so the controller (which owns + * the messenger) supplies the concrete `UserStorageController` calls. + */ + +/** + * The Encrypted User Storage operations this module needs. On MetaMask clients + * these are backed by `UserStorageController:performGetStorage` / + * `performSetStorage`. + */ +export type UkycLocalUserSecretStore = { + /** + * Reads the base64 string stored at `path`, or `null` if none exists. + */ + get: (path: string, entropySourceId?: string) => Promise; + /** + * Writes the base64 string `value` at `path`. + */ + set: ( + path: string, + value: string, + entropySourceId?: string, + ) => Promise; +}; + +/** + * In-flight `getOrCreateLocalUserSecret` calls, keyed by entropy source. + * Deduplicates concurrent enrollments in a single client session so we never + * generate and persist two competing `local_user_secret`s for the same source. + */ +const inFlightCreations = new Map>(); + +/** + * Loads the persisted `local_user_secret` from Encrypted User Storage, if one + * exists. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The decoded `local_user_secret` bytes, or `null` if none has been + * enrolled. + */ +export async function loadLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const stored = await store.get(UKYC_LOCAL_USER_SECRET_PATH, entropySourceId); + + if (!stored) { + return null; + } + + const localUserSecret = base64ToBytes(stored); + + if (localUserSecret.length !== UKYC_LOCAL_USER_SECRET_SIZE_BYTES) { + throw new Error( + `UKYC: stored local_user_secret has unexpected length ${localUserSecret.length}, expected ${UKYC_LOCAL_USER_SECRET_SIZE_BYTES}.`, + ); + } + + return localUserSecret; +} + +/** + * Persists a freshly generated `local_user_secret` to Encrypted User Storage. + * + * @param store - The Encrypted User Storage adapter. + * @param localUserSecret - The `local_user_secret` bytes to persist. + * @param entropySourceId - Optional HD keyring entropy source id. + */ +async function persistLocalUserSecret( + store: UkycLocalUserSecretStore, + localUserSecret: Uint8Array, + entropySourceId?: string, +): Promise { + await store.set( + UKYC_LOCAL_USER_SECRET_PATH, + bytesToBase64(localUserSecret), + entropySourceId, + ); +} + +/** + * Creates the UKYC `local_user_secret` if it does not already exist, otherwise + * loads the existing one. This is the single entry point used on UKYC + * enrollment. + * + * The operation is idempotent and safe against concurrent callers in the same + * session: repeated or parallel calls resolve to the same `local_user_secret` + * and never generate more than one secret for a given entropy source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id, used to scope + * the secret to a specific SRP in multi-SRP wallets. Defaults to the primary SRP. + * @returns The `local_user_secret` bytes (existing or newly created). + */ +export async function getOrCreateLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + const cacheKey = entropySourceId ?? ''; + + const pending = inFlightCreations.get(cacheKey); + if (pending) { + return pending; + } + + const creation = (async (): Promise => { + const existing = await loadLocalUserSecret(store, entropySourceId); + if (existing) { + return existing; + } + + const localUserSecret = randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + await persistLocalUserSecret(store, localUserSecret, entropySourceId); + + // Re-read after persisting so that all callers converge on whatever value + // actually landed in storage (defends against a competing write that may + // have won the race, e.g. from another device syncing the same feature). + return ( + (await loadLocalUserSecret(store, entropySourceId)) ?? localUserSecret + ); + })(); + + inFlightCreations.set(cacheKey, creation); + + try { + return await creation; + } finally { + inFlightCreations.delete(cacheKey); + } +} + +/** + * Whether a `local_user_secret` has already been enrolled for the given entropy + * source. + * + * @param store - The Encrypted User Storage adapter. + * @param entropySourceId - Optional HD keyring entropy source id. + * @returns `true` if a `local_user_secret` exists in Encrypted User Storage. + */ +export async function hasLocalUserSecret( + store: UkycLocalUserSecretStore, + entropySourceId?: string, +): Promise { + return (await loadLocalUserSecret(store, entropySourceId)) !== null; +} diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts new file mode 100644 index 00000000000..e656cb8c6bc --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -0,0 +1,226 @@ +import { base64ToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; + +import { + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants'; +import { deriveClientMaterial } from './deriveClientMaterial'; +import { + canonicalizeJson, + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken'; + +const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 42, +); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); + +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Decodes an unpadded base64url string back to bytes. + * + * @param value - The base64url string. + * @returns The decoded bytes. + */ +function fromBase64Url(value: string): Uint8Array { + const padded = value.padEnd(Math.ceil(value.length / 4) * 4, '='); + return base64ToBytes(padded.replace(/-/gu, '+').replace(/_/gu, '/')); +} + +describe('UKYC canonicalizeJson', () => { + it('sorts object keys by code unit', () => { + expect(canonicalizeJson({ b: 1, a: 2, c: 3 })).toBe('{"a":2,"b":1,"c":3}'); + }); + + it('preserves array order and emits no whitespace', () => { + expect(canonicalizeJson({ z: [3, 2, 1], a: 'x' })).toBe( + '{"a":"x","z":[3,2,1]}', + ); + }); + + it('drops undefined members', () => { + expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe('{"a":1,"c":2}'); + }); + + it('serializes primitives', () => { + expect(canonicalizeJson(null)).toBe('null'); + expect(canonicalizeJson(true)).toBe('true'); + expect(canonicalizeJson(false)).toBe('false'); + expect(canonicalizeJson('hi')).toBe('"hi"'); + expect(canonicalizeJson(7)).toBe('7'); + }); + + it('rejects non-integer numbers', () => { + expect(() => canonicalizeJson(1.5)).toThrow('non-integer'); + }); +}); + +describe('UKYC signStorageAccessToken', () => { + it('mints a client-presented token with the expected payload', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload).toStrictEqual({ + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + storage_id: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + signing_public_key: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), + operations: ['delete'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00.000Z', + expires_at: '2026-07-07T04:00:00.000Z', + }); + expect(token.payload).not.toHaveProperty('session_id'); + }); + + it('produces a signature that verifies against the signing public key', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const message = new TextEncoder().encode(canonicalizeJson(token.payload)); + const signature = fromBase64Url(token.signature); + + expect(ed25519.verify(signature, message, MATERIAL.signingPublicKey)).toBe( + true, + ); + }); + + it('binds session_id for Relay-presented tokens', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + sessionId: 'session-42', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(token.payload.session_id).toBe('session-42'); + expect(token.payload.presenter).toBe('idos-relay'); + }); + + it('is deterministic for the same inputs', () => { + const params = { + material: MATERIAL, + operations: ['read' as const], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(signStorageAccessToken(params)).toStrictEqual( + signStorageAccessToken(params), + ); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); + + it('rejects delegating a delete token to the Relay', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('cannot be delegated to the Relay'); + }); + + it('rejects delete combined with other operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['delete', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must contain only "delete"'); + }); + + it('rejects an empty operations list', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: [], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('at least one operation'); + }); + + it('rejects duplicate operations', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read', 'read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('must be unique'); + }); + + it('rejects an expiry at or before issued_at', () => { + expect(() => + signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: EXPIRES_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('expires_at must be after issued_at'); + }); + + it('defaults issuedAt to now when omitted', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + expiresAt: new Date(Date.now() + 60_000), + }); + + expect(token.payload.issued_at).toStrictEqual(expect.any(String)); + }); +}); + +describe('UKYC encodeStorageAccessTokenForHeader', () => { + it('encodes the envelope as unpadded base64url that round-trips', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const header = encodeStorageAccessTokenForHeader(token); + + expect(header).toMatch(/^[A-Za-z0-9_-]+$/u); + expect( + JSON.parse(new TextDecoder().decode(fromBase64Url(header))), + ).toStrictEqual(token); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts new file mode 100644 index 00000000000..58d1b54c55c --- /dev/null +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -0,0 +1,245 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { stringToBytes } from '@metamask/utils'; + +import { + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './constants'; +import type { UkycClientMaterial } from './deriveClientMaterial'; +import { toBase64Url } from './encoding'; + +/** + * Mints `storage_access_token` capabilities — the client-signed, scoped, + * session-bound proofs that authorize UKYC storage operations. See the + * architecture doc, section "Storage Authentication". + * + * The token is Ed25519 over RFC 8785 (JCS) canonical JSON of the payload. Only + * the client holds the private `signing_key`, so only the client can mint a + * token; a `read`/`write`-scoped token may then be handed to the Relay to + * present, but `delete` is never delegated. + */ + +/** + * Storage operations a `storage_access_token` can authorize. + */ +export type UkycStorageOperation = 'read' | 'write' | 'delete'; + +/** + * Who presents the token to UKYC storage. The Relay may only present + * `read`/`write` tokens; `delete` is always client-presented. + */ +export type UkycTokenPresenter = 'client' | 'idos-relay'; + +/** + * The signed `storage_access_token` payload. Field names are snake_case because + * they are canonicalized and hashed exactly as they appear on the wire. + */ +export type UkycStorageAccessTokenPayload = { + version: number; + aud: string; + // Wire-shape fields are snake_case; they are canonicalized and signed exactly + // as they appear on the wire. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + signing_public_key: string; + operations: UkycStorageOperation[]; + presenter: UkycTokenPresenter; + /** UKYC session id. Required (and only present) when presenter is `idos-relay`. */ + session_id?: string; + issued_at: string; + expires_at: string; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * The on-the-wire envelope: the payload plus its detached Ed25519 signature + * (base64url) over the JCS canonicalization of the payload. + */ +export type UkycStorageAccessToken = { + payload: UkycStorageAccessTokenPayload; + signature: string; +}; + +/** + * Inputs for minting a `storage_access_token`. + */ +export type SignStorageAccessTokenParams = { + /** Client material derived from `local_user_secret`. */ + material: UkycClientMaterial; + /** Operations the token authorizes. `delete` must be the sole operation. */ + operations: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when presenter is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Must be strictly after `issuedAt`. */ + expiresAt: Date; +}; + +type JsonValue = + | null + | boolean + | number + | string + | JsonValue[] + | { [key: string]: JsonValue | undefined }; + +/** + * Serializes a JSON value to RFC 8785 (JCS) canonical form. + * + * Scope note: this implementation covers the JSON shapes used by UKYC storage + * payloads — objects, arrays, strings, integers, booleans, and null. Object + * members are sorted by their UTF-16 code units (matching JS default string + * ordering, which is what JCS requires) and `undefined` members are dropped. + * Non-finite and non-integer numbers are rejected, since the payloads never + * contain them and correct JCS number formatting for the general case is + * intentionally out of scope here. + * + * @param value - The value to canonicalize. + * @returns The canonical JSON string. + */ +export function canonicalizeJson(value: JsonValue): string { + if (value === null) { + return 'null'; + } + + if (typeof value === 'boolean') { + return value ? 'true' : 'false'; + } + + if (typeof value === 'number') { + if (!Number.isInteger(value)) { + throw new Error('UKYC: cannot canonicalize a non-integer number for JCS.'); + } + return JSON.stringify(value); + } + + if (typeof value === 'string') { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => canonicalizeJson(item)).join(',')}]`; + } + + const entries = Object.keys(value) + .sort() + .reduce((acc, key) => { + const child = value[key]; + if (child !== undefined) { + acc.push(`${JSON.stringify(key)}:${canonicalizeJson(child)}`); + } + return acc; + }, []); + + return `{${entries.join(',')}}`; +} + +/** + * Builds and signs a `storage_access_token`. + * + * @param params - See {@link SignStorageAccessTokenParams}. + * @returns The signed token envelope. + */ +export function signStorageAccessToken( + params: SignStorageAccessTokenParams, +): UkycStorageAccessToken { + const { + material, + operations, + presenter = 'client', + sessionId, + issuedAt = new Date(), + expiresAt, + } = params; + + assertValidOperations(operations); + + if (expiresAt.getTime() <= issuedAt.getTime()) { + throw new Error( + 'UKYC: storage_access_token expires_at must be after issued_at.', + ); + } + + const isDelete = operations.includes('delete'); + + if (presenter === 'idos-relay' && isDelete) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token cannot be delegated to the Relay.', + ); + } + + if (presenter === 'idos-relay' && !sessionId) { + throw new Error( + 'UKYC: a Relay-presented storage_access_token requires a session_id.', + ); + } + + const payload: UkycStorageAccessTokenPayload = { + version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, + aud: UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + storage_id: toBase64Url(material.storageId), + signing_public_key: toBase64Url(material.signingPublicKey), + operations, + presenter, + issued_at: issuedAt.toISOString(), + expires_at: expiresAt.toISOString(), + }; + + // Only bind session_id for Relay-presented tokens; omit the key entirely for + // client-presented tokens so it does not appear in the canonicalized payload. + if (presenter === 'idos-relay') { + payload.session_id = sessionId; + } + + const message = stringToBytes(canonicalizeJson(payload)); + const signature = ed25519.sign(message, material.signingKey); + + return { + payload, + signature: toBase64Url(signature), + }; +} + +/** + * Serializes a signed token to a compact string suitable for header transport + * (e.g. `Authorization: StorageAccessToken `). The complete envelope is + * base64url-encoded; the private `signing_key` is never included. + * + * @param token - The signed token envelope. + * @returns The base64url-encoded envelope string. + */ +export function encodeStorageAccessTokenForHeader( + token: UkycStorageAccessToken, +): string { + return toBase64Url(stringToBytes(JSON.stringify(token))); +} + +/** + * Validates that an operations list is one storage understands: a non-empty set + * of `read`/`write`, or exactly `['delete']`. `delete` is never combined with + * other operations. + * + * @param operations - The requested operations. + */ +function assertValidOperations(operations: UkycStorageOperation[]): void { + if (operations.length === 0) { + throw new Error( + 'UKYC: storage_access_token requires at least one operation.', + ); + } + + const unique = new Set(operations); + + if (unique.size !== operations.length) { + throw new Error('UKYC: storage_access_token operations must be unique.'); + } + + if (unique.has('delete') && operations.length > 1) { + throw new Error( + 'UKYC: a delete-scoped storage_access_token must contain only "delete".', + ); + } +} diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts new file mode 100644 index 00000000000..1560af07798 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -0,0 +1,91 @@ +import { areUint8ArraysEqual } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; + +import { base64UrlToBytes, toBase64Url } from './encoding'; +import { wrapEncryptionKey } from './wrapEncryptionKey'; + +const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); + +/** + * Reverses {@link wrapEncryptionKey} from the server's perspective: derives the + * same shared secret from the server private key + client public key and + * decrypts. + * + * @param serverPrivateKey - The server's X25519 private key. + * @param clientPublicKey - The client's X25519 public key. + * @param encryptedKey - The base64url ciphertext (+ tag). + * @param nonce - The base64url nonce. + * @returns The recovered plaintext key. + */ +function unwrap( + serverPrivateKey: Uint8Array, + clientPublicKey: Uint8Array, + encryptedKey: string, + nonce: string, +): Uint8Array { + const shared = x25519.getSharedSecret(serverPrivateKey, clientPublicKey); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + return gcm(aeadKey, base64UrlToBytes(nonce)).decrypt( + base64UrlToBytes(encryptedKey), + ); +} + +describe('UKYC wrapEncryptionKey', () => { + it('wraps a key the session server can recover', () => { + const serverPrivateKey = x25519.utils.randomSecretKey(); + const serverPublicKey = x25519.getPublicKey(serverPrivateKey); + const clientPrivateKey = x25519.utils.randomSecretKey(); + const clientPublicKey = x25519.getPublicKey(clientPrivateKey); + + const { encryptedKey, nonce } = wrapEncryptionKey( + clientPrivateKey, + toBase64Url(serverPublicKey), + DATA_ENCRYPTION_KEY, + ); + + const recovered = unwrap( + serverPrivateKey, + clientPublicKey, + encryptedKey, + nonce, + ); + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('emits unpadded base64url fields', () => { + const serverPublicKey = x25519.getPublicKey(x25519.utils.randomSecretKey()); + const clientPrivateKey = x25519.utils.randomSecretKey(); + + const { encryptedKey, nonce } = wrapEncryptionKey( + clientPrivateKey, + toBase64Url(serverPublicKey), + DATA_ENCRYPTION_KEY, + ); + + expect(encryptedKey).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/u); + }); + + it('uses a fresh nonce per call', () => { + const serverPublicKey = x25519.getPublicKey(x25519.utils.randomSecretKey()); + const clientPrivateKey = x25519.utils.randomSecretKey(); + const serverPublicKeyB64 = toBase64Url(serverPublicKey); + + const first = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + const second = wrapEncryptionKey( + clientPrivateKey, + serverPublicKeyB64, + DATA_ENCRYPTION_KEY, + ); + + expect(first.nonce).not.toBe(second.nonce); + expect(first.encryptedKey).not.toBe(second.encryptedKey); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts new file mode 100644 index 00000000000..fad46237512 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -0,0 +1,62 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { randomBytes } from '@noble/hashes/utils'; + +import { base64UrlToBytes, toBase64Url } from './encoding'; + +/** + * Wraps the `data_encryption_key` for the UKYC session server using the + * static-static ECDH established with the server's per-session wrapping key. + * + * Unlike {@link wrapUserKey} (which generates a fresh ephemeral keypair per + * call), this uses the session client keypair whose public half was already + * handed to the server via `POST /wrapping-key`. The server therefore already + * knows our public key and can derive the same shared secret from its session + * private key, so only `{ encryptedKey, nonce }` need be transmitted. + */ + +/** 96-bit nonce, the AES-GCM standard nonce size. */ +const NONCE_SIZE_BYTES = 12; + +/** + * The transmitted portion of a wrapped encryption key: the AES-256-GCM + * ciphertext (which includes the 16-byte auth tag) and the nonce, both + * base64url-encoded. + */ +export type WrappedEncryptionKeyParts = { + encryptedKey: string; + nonce: string; +}; + +/** + * Wraps `keyToWrap` for the UKYC session server. + * + * The AEAD key is the ECDH shared secret between our session client private key + * and the session server public key returned by `getWrappingKey`, run through + * HKDF-SHA256; the key is then sealed with AES-256-GCM. + * + * @param sessionClientPrivateKey - Our session's X25519 private key. + * @param sessionServerPublicKey - The server's X25519 public key (base64url). + * @param keyToWrap - The raw symmetric key bytes to encrypt. + * @returns The base64url `encryptedKey` (ciphertext + tag) and `nonce`. + */ +export function wrapEncryptionKey( + sessionClientPrivateKey: Uint8Array, + sessionServerPublicKey: string, + keyToWrap: Uint8Array, +): WrappedEncryptionKeyParts { + const serverPublicKey = base64UrlToBytes(sessionServerPublicKey); + const shared = x25519.getSharedSecret( + sessionClientPrivateKey, + serverPublicKey, + ); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + const nonce = randomBytes(NONCE_SIZE_BYTES); + const encryptedKey = gcm(aeadKey, nonce).encrypt(keyToWrap); + return { + encryptedKey: toBase64Url(encryptedKey), + nonce: toBase64Url(nonce), + }; +} diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts new file mode 100644 index 00000000000..4a1ef57114c --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts @@ -0,0 +1,114 @@ +import { areUint8ArraysEqual, base64ToBytes } from '@metamask/utils'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex } from '@noble/hashes/utils'; + +import { wrapUserKey } from './wrapUserKey'; + +const X25519_KEY_SIZE_BYTES = 32; +const IV_SIZE_BYTES = 12; + +const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); + +/** + * Decodes the unpadded base64url blob produced by {@link wrapUserKey}. + * + * @param value - The base64url-encoded wrapped key. + * @returns The raw blob bytes. + */ +function fromBase64Url(value: string): Uint8Array { + return base64ToBytes( + value + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd(value.length + ((4 - (value.length % 4)) % 4), '='), + ); +} + +/** + * Reverses {@link wrapUserKey} with the recipient's private key. + * + * @param blob - The base64url wrapped key. + * @param recipientPrivateKey - The recipient's X25519 private key. + * @returns The recovered plaintext key bytes. + */ +function unwrapUserKey( + blob: string, + recipientPrivateKey: Uint8Array, +): Uint8Array { + const bytes = fromBase64Url(blob); + const ephemeralPublicKey = bytes.slice(0, X25519_KEY_SIZE_BYTES); + const iv = bytes.slice( + X25519_KEY_SIZE_BYTES, + X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES, + ); + const ciphertext = bytes.slice(X25519_KEY_SIZE_BYTES + IV_SIZE_BYTES); + + const shared = x25519.getSharedSecret( + recipientPrivateKey, + ephemeralPublicKey, + ); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + return gcm(aeadKey, iv).decrypt(ciphertext); +} + +describe('UKYC wrapUserKey', () => { + it('produces an unpadded base64url blob', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + + expect(wrapped).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(wrapped).not.toContain('='); + }); + + it('wraps a key the recipient can recover (hex public key)', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const wrapped = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + const recovered = unwrapUserKey(wrapped, privateKey); + + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('accepts a base64url-encoded public key', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + const publicKeyBase64Url = Buffer.from(publicKey) + .toString('base64') + .replace(/\+/gu, '-') + .replace(/\//gu, '_') + .replace(/[=]+$/u, ''); + + const wrapped = wrapUserKey(publicKeyBase64Url, DATA_ENCRYPTION_KEY); + const recovered = unwrapUserKey(wrapped, privateKey); + + expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); + }); + + it('produces a fresh ephemeral key (non-deterministic output) per call', () => { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + + const first = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + const second = wrapUserKey(bytesToHex(publicKey), DATA_ENCRYPTION_KEY); + + expect(first).not.toStrictEqual(second); + expect( + areUint8ArraysEqual( + unwrapUserKey(first, privateKey), + unwrapUserKey(second, privateKey), + ), + ).toBe(true); + }); + + it('rejects a public key of the wrong length', () => { + expect(() => wrapUserKey('abcd', DATA_ENCRYPTION_KEY)).toThrow( + 'unexpected length', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts new file mode 100644 index 00000000000..96eb830a63b --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts @@ -0,0 +1,101 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { hexToBytes, randomBytes } from '@noble/hashes/utils'; +import { base64ToBytes } from '@metamask/utils'; + +import { toBase64Url } from './encoding'; + +/** + * Wraps (encrypts) a symmetric key so that only the holder of a given X25519 + * private key can recover it — used to seal the `data_encryption_key` for the + * idOS Relay before it is handed to the UKYC API as `wrappedUserKey`. + */ + +/** X25519 public/private keys and AES-256-GCM keys are all 32 bytes. */ +const X25519_KEY_SIZE_BYTES = 32; + +/** 96-bit IV, the AES-GCM standard nonce size. */ +const IV_SIZE_BYTES = 12; + +/** + * Decodes an X25519 public key presented as either hex or base64/base64url. + * + * @param recipientPublicKey - The recipient's 32-byte X25519 public key. + * @returns The raw 32-byte public key. + */ +function decodePublicKey(recipientPublicKey: string): Uint8Array { + const isHex = + recipientPublicKey.length === X25519_KEY_SIZE_BYTES * 2 && + /^[0-9a-fA-F]+$/u.test(recipientPublicKey); + + const bytes = isHex + ? hexToBytes(recipientPublicKey) + : base64ToBytes( + recipientPublicKey + .replace(/-/gu, '+') + .replace(/_/gu, '/') + .padEnd( + recipientPublicKey.length + + ((4 - (recipientPublicKey.length % 4)) % 4), + '=', + ), + ); + + if (bytes.length !== X25519_KEY_SIZE_BYTES) { + throw new Error( + `UKYC: wrappingPublicKey has unexpected length ${bytes.length}, expected ${X25519_KEY_SIZE_BYTES}.`, + ); + } + return bytes; +} + +/** + * Wraps `keyToWrap` for the holder of `recipientPublicKey` using an + * ephemeral-static ECDH + AES-256-GCM sealed-box scheme: + * + * ephemeral = fresh X25519 keypair (one per call) + * shared = X25519(ephemeralPrivate, recipientPublic) + * aeadKey = HKDF-SHA256(shared, 32 bytes) + * iv = 12 random bytes + * ct = AES-256-GCM(aeadKey, iv).encrypt(keyToWrap) // ct includes tag + * + * The recipient reverses it with their private key: + * + * shared = X25519(recipientPrivate, ephemeralPublic) + * aeadKey = HKDF-SHA256(shared, 32 bytes) + * key = AES-256-GCM(aeadKey, iv).decrypt(ct) + * + * This mirrors the X25519 + AES-256-GCM/HKDF decryption used for MoonPay + * Check/Auth-frame credentials, so both directions share one primitive. + * + * @param recipientPublicKey - The recipient's X25519 public key (hex or base64). + * @param keyToWrap - The raw symmetric key bytes to encrypt (e.g. the + * `data_encryption_key`). + * @returns Base64url of `ephemeralPublicKey(32) || iv(12) || ciphertext+tag`. + */ +export function wrapUserKey( + recipientPublicKey: string, + keyToWrap: Uint8Array, +): string { + const recipient = decodePublicKey(recipientPublicKey); + + const ephemeralPrivateKey = x25519.utils.randomSecretKey(); + const ephemeralPublicKey = x25519.getPublicKey(ephemeralPrivateKey); + + const shared = x25519.getSharedSecret(ephemeralPrivateKey, recipient); + const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); + + const iv = randomBytes(IV_SIZE_BYTES); + const ciphertext = gcm(aeadKey, iv).encrypt(keyToWrap); + + const blob = new Uint8Array( + ephemeralPublicKey.length + iv.length + ciphertext.length, + ); + blob.set(ephemeralPublicKey, 0); + blob.set(iv, ephemeralPublicKey.length); + blob.set(ciphertext, ephemeralPublicKey.length + iv.length); + + return toBase64Url(blob); +} diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts new file mode 100644 index 00000000000..0e8e2b76b71 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts @@ -0,0 +1,90 @@ +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants'; +import { deriveClientMaterial } from './deriveClientMaterial'; +import type { UkycStorageAccessToken } from './storageAccessToken'; +import { signStorageAccessToken } from './storageAccessToken'; +import { buildWrappedRelayPayload } from './wrappedRelayPayload'; + +const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( + 42, +); +const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); +const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); + +/** + * Mints a token with a given presenter/operations for the tests below. + * + * @param presenter - The token presenter. + * @param operations - The token operations. + * @returns The signed token. + */ +function tokenFor( + presenter: 'client' | 'idos-relay', + operations: ('read' | 'write' | 'delete')[], +): UkycStorageAccessToken { + return signStorageAccessToken({ + material: MATERIAL, + operations, + presenter, + sessionId: presenter === 'idos-relay' ? 'session-1' : undefined, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); +} + +describe('UKYC buildWrappedRelayPayload', () => { + it('bundles the Relay-facing material with the token', () => { + const token = tokenFor('idos-relay', ['read', 'write']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(payload.storage_id).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.data_encryption_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.signing_public_key).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(payload.storage_access_token).toBe(token); + }); + + it('shares the data_encryption_key with the Relay', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + // The DEK is intentionally included so the Relay can encrypt/decrypt. + expect(payload.data_encryption_key.length).toBeGreaterThan(0); + }); + + it('never leaks the local secret or private signing key', () => { + const token = tokenFor('idos-relay', ['read']); + + const payload = buildWrappedRelayPayload(MATERIAL, token); + + expect(Object.keys(payload).sort()).toStrictEqual([ + 'data_encryption_key', + 'signing_public_key', + 'storage_access_token', + 'storage_id', + ]); + }); + + it('rejects a client-presented token', () => { + const token = tokenFor('client', ['read']); + + expect(() => buildWrappedRelayPayload(MATERIAL, token)).toThrow( + 'requires a Relay-presented storage_access_token', + ); + }); + + it('rejects a delete-scoped token', () => { + // A delete token cannot be Relay-presented, so craft one that slips past + // signing by mutating the presenter after the fact. + const token = tokenFor('client', ['delete']); + const relayDeleteToken = { + ...token, + payload: { ...token.payload, presenter: 'idos-relay' as const }, + }; + + expect(() => buildWrappedRelayPayload(MATERIAL, relayDeleteToken)).toThrow( + 'must not carry a delete-scoped storage_access_token', + ); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts new file mode 100644 index 00000000000..b67e4a737ce --- /dev/null +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -0,0 +1,65 @@ +import type { UkycClientMaterial } from './deriveClientMaterial'; +import { toBase64Url } from './encoding'; +import type { UkycStorageAccessToken } from './storageAccessToken'; + +/** + * Builds the `wrapped_relay_payload` — the only client-derived material that + * leaves the device. It is sent to the idOS Relay (through the UKYC API) so the + * Relay can encrypt/decrypt and store KYC payloads on the user's behalf. See the + * architecture doc, section "Client-Derived Material". + * + * Neither `local_user_secret` nor the private `signing_key` are ever included. + * The `data_encryption_key` *is* included: it is intentionally shared with the + * Relay so it can encrypt/decrypt payloads transiently. + */ + +/** + * The Relay-facing bundle. `data_encryption_key` is base64url-encoded secret + * material shared with the Relay; the other fields are non-secret. + */ +export type UkycWrappedRelayPayload = { + // Wire-shape fields are snake_case. + /* eslint-disable @typescript-eslint/naming-convention */ + storage_id: string; + data_encryption_key: string; + signing_public_key: string; + storage_access_token: UkycStorageAccessToken; + /* eslint-enable @typescript-eslint/naming-convention */ +}; + +/** + * Assembles the `wrapped_relay_payload` from derived client material and a + * Relay-presented `storage_access_token`. + * + * The token must be scoped for the Relay to present (`presenter: 'idos-relay'`) + * and must not authorize `delete`, which is never delegated to the Relay. + * + * @param material - Client material derived from `local_user_secret`. + * @param storageAccessToken - A `read`/`write`-scoped, Relay-presented token. + * @returns The `wrapped_relay_payload` to send to the Relay via the UKYC API. + */ +export function buildWrappedRelayPayload( + material: UkycClientMaterial, + storageAccessToken: UkycStorageAccessToken, +): UkycWrappedRelayPayload { + const { presenter, operations } = storageAccessToken.payload; + + if (presenter !== 'idos-relay') { + throw new Error( + 'UKYC: wrapped_relay_payload requires a Relay-presented storage_access_token.', + ); + } + + if (operations.includes('delete')) { + throw new Error( + 'UKYC: wrapped_relay_payload must not carry a delete-scoped storage_access_token.', + ); + } + + return { + storage_id: toBase64Url(material.storageId), + data_encryption_key: toBase64Url(material.dataEncryptionKey), + signing_public_key: toBase64Url(material.signingPublicKey), + storage_access_token: storageAccessToken, + }; +} From b9bc73506143a4b73ba4aaab9588f205f00a9784 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Tue, 28 Jul 2026 11:48:12 -0700 Subject: [PATCH 22/67] add ukycCapabilityToken submission --- packages/kyc-controller/README.md | 4 +-- .../kyc-controller/src/KycController.test.ts | 10 +++++- packages/kyc-controller/src/KycController.ts | 25 ++++++++++++-- .../src/KycService-method-action-types.ts | 3 +- .../kyc-controller/src/KycService.test.ts | 34 +++++++++++++++++-- packages/kyc-controller/src/KycService.ts | 19 +++++++++-- 6 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 84906e80e89..d53a7629622 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -155,7 +155,7 @@ Endpoints: | `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | | `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | | `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | -| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | | `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | ### 2.3 `crypto.ts` @@ -354,7 +354,7 @@ sequenceDiagram Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) opt kycRequired === true → auto-launch document verification - Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata }) + Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken }) Svc->>API: POST /sessions Ctrl->>Svc: submitWrappedKey({ sessionId, wrappedUserKey, ... }) Svc->>API: POST /sessions/{id}/wrapped-key diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 46edcfb405a..b2feaf9d84f 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1239,13 +1239,21 @@ describe('KycController', () => { expect(result).toStrictEqual({ ok: true }); expect(controller.state.sumsub.status).toBe('complete'); expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); - // The wrapped key is handed over once at session creation. + // The wrapped key and a read-only capability token are handed over + // once at session creation. expect(handlers.createUkycSession).toHaveBeenCalledWith( expect.objectContaining({ wrappedEncryptionKey: expect.objectContaining({ sessionId: 'wk', encryptedKey: 'enc', }), + ukycCapabilityToken: expect.objectContaining({ + payload: expect.objectContaining({ + operations: ['read'], + presenter: 'client', + }), + signature: expect.any(String), + }), }), ); // onTokenExpiration re-fetches the applicant access token. diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index b6c3e4dc7df..cf0b43a6cd4 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -26,6 +26,7 @@ import type { import { deriveClientMaterial, getOrCreateLocalUserSecret, + signStorageAccessToken, toBase64Url, verifyJwtChain, wrapEncryptionKey, @@ -45,6 +46,12 @@ const CHANNEL_RESET = 'ch_reset'; // must be replaced with real UKYC-issued material before production use. const MOCK_JWT_TOKEN = 'mock-jwt-token'; +// Lifetime of the read-only `ukyc_capability_token` minted when creating a +// UKYC session. The storage-and-auth spec requires the token's `expires_at` to +// cover the KYC session's expected lifetime — including the provider journey — +// rather than a fixed short window, so this is a session-scoped window. +const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000; + // The SumSub SDK status that signals the applicant finished the flow // successfully. Any other resolution (abandonment, failure, or a non-success // outcome) must not be recorded as `complete`. @@ -960,7 +967,8 @@ export class KycController extends BaseController< * attested session server public key; * 3. derives the `data_encryption_key` from the wallet's UKYC * `local_user_secret` and wraps it for the session server; - * 4. creates the UKYC session (handing over the wrapped key); + * 4. mints a client-signed, read-only `ukyc_capability_token` and creates + * the UKYC session (handing over the wrapped key and the token); * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * @@ -1027,16 +1035,26 @@ export class KycController extends BaseController< const localUserSecret = await getOrCreateLocalUserSecret( this.#localUserSecretStore(), ); - const { dataEncryptionKey } = deriveClientMaterial(localUserSecret); + const clientMaterial = deriveClientMaterial(localUserSecret); const wrappedEncryptionKey = { sessionId: wrappingKey.id, ...wrapEncryptionKey( sessionClientPrivateKey, wrappingKey.sessionServerPublicKey.x, - dataEncryptionKey, + clientMaterial.dataEncryptionKey, ), }; + // Mint a read-only `ukyc_capability_token` for the session. Only the + // client holds the signing key derived from `local_user_secret`, so only + // the client can mint it; scoping it to `read` means it authorizes later + // storage reads without granting write or delete access. + const ukycCapabilityToken = signStorageAccessToken({ + material: clientMaterial, + operations: ['read'], + expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), + }); + const { sessionId, idosSessionId } = await this.messenger.call( 'KycService:createUkycSession', { @@ -1046,6 +1064,7 @@ export class KycController extends BaseController< moonPayUserId: this.state.moonpayCustomerId, }, wrappedEncryptionKey, + ukycCapabilityToken, }, ); diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index be1b8fb8800..6ae4909d3e7 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -88,7 +88,8 @@ export type KycServiceFetchJwksAction = { /** * Creates a UKYC session for the SumSub document-verification sub-flow, - * handing over the wrapped `data_encryption_key`. + * handing over the wrapped `data_encryption_key` and a client-signed, + * read-only `ukyc_capability_token`. * * @param params - The session parameters. * @returns The UKYC session identifiers. diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 4e6e898b237..228418b9c95 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -8,6 +8,12 @@ import nock, { cleanAll } from 'nock'; import type { KycServiceMessenger } from './KycService'; import { KycService } from './KycService'; +import { + deriveClientMaterial, + encodeStorageAccessTokenForHeader, + signStorageAccessToken, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './ukyc'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; @@ -260,13 +266,35 @@ describe('KycService', () => { nonce: 'nonce', }; - it('creates a UKYC session and forwards the wrapped key', async () => { + // A genuinely signed, read-only capability token: minted from real derived + // client material and signed with the Ed25519 `signingKey`, not a + // hand-written plain object. + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(42), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + issuedAt: new Date('2026-07-07T00:00:00.000Z'), + expiresAt: new Date('2026-07-07T04:00:00.000Z'), + }); + // The service base64url-encodes the envelope into a compact string before + // sending it in the request body. + const encodedCapabilityToken = + encodeStorageAccessTokenForHeader(ukycCapabilityToken); + + it('creates a UKYC session and forwards the wrapped key and capability token', async () => { const response = { sessionId: 'sid', idosSessionId: 'iss', }; nock(MOCK_API_URL) - .post('/sessions', (body) => body.wrappedEncryptionKey !== undefined) + .post( + '/sessions', + (body) => + body.wrappedEncryptionKey !== undefined && + body.ukycCapabilityToken === encodedCapabilityToken, + ) .reply(200, response); const { service } = getService(); @@ -275,6 +303,7 @@ describe('KycService', () => { jwtToken: 'jwt', vendorMetadata: { foo: 'bar' }, wrappedEncryptionKey, + ukycCapabilityToken, }), ).toStrictEqual(response); }); @@ -288,6 +317,7 @@ describe('KycService', () => { jwtToken: 'jwt', vendorMetadata: {}, wrappedEncryptionKey, + ukycCapabilityToken, }), ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index f12abe00f42..b7b0e2d1009 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -19,7 +19,8 @@ import { import { alpha2ToAlpha3 } from './countryCodes'; import type { KycServiceMethodActions } from './KycService-method-action-types'; import type { KycDisclaimer } from './types'; -import { UKYC_JWKS_PATH } from './ukyc'; +import { encodeStorageAccessTokenForHeader, UKYC_JWKS_PATH } from './ukyc'; +import type { UkycStorageAccessToken } from './ukyc'; // === GENERAL === @@ -194,6 +195,15 @@ export type CreateUkycSessionParams = { jwtToken: string; vendorMetadata: Record; wrappedEncryptionKey: WrappedEncryptionKey; + /** + * The client-signed `ukyc_capability_token` (envelope: payload + Ed25519 + * signature) authorizing later storage access for this session. It is minted + * by the client with `read`-only scope — see the UKYC storage-and-auth spec + * for how it is formed. Only the client holds the signing key, so only the + * client can mint it. The envelope is base64url-encoded into a compact string + * before it is sent to the backend. + */ + ukycCapabilityToken: UkycStorageAccessToken; }; export type FetchApplicantAccessTokenParams = { @@ -416,7 +426,9 @@ export class KycService { /** * Creates a UKYC session for the SumSub document-verification sub-flow, - * handing over the wrapped `data_encryption_key`. + * handing over the wrapped `data_encryption_key` and the client-signed, + * read-only `ukyc_capability_token` that authorizes later storage access for + * the session. * * @param params - The session parameters. * @returns The UKYC session identifiers. @@ -433,6 +445,9 @@ export class KycService { jwtToken: params.jwtToken, vendorMetadata: params.vendorMetadata, wrappedEncryptionKey: params.wrappedEncryptionKey, + ukycCapabilityToken: encodeStorageAccessTokenForHeader( + params.ukycCapabilityToken, + ), }), }); return this.#validateResponse( From 02afb3a3360def225466a9f7ffa1fd30c2316645 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Tue, 28 Jul 2026 15:17:02 -0700 Subject: [PATCH 23/67] WIP --- packages/kyc-controller/package.json | 3 +- .../src/ukyc/wrapEncryptionKey.test.ts | 55 +++++++++---------- .../src/ukyc/wrapEncryptionKey.ts | 45 +++++++-------- yarn.lock | 1 + 4 files changed, 50 insertions(+), 54 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 2c5b3b1d55d..12ecdd97ee9 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -67,7 +67,8 @@ "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", - "reselect": "^5.1.1" + "reselect": "^5.1.1", + "tweetnacl": "^1.0.3" }, "devDependencies": { "@metamask/auto-changelog": "^6.1.0", diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts index 1560af07798..020e5cbc697 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -1,18 +1,14 @@ -import { areUint8ArraysEqual } from '@metamask/utils'; -import { gcm } from '@noble/ciphers/aes'; -import { x25519 } from '@noble/curves/ed25519'; -import { hkdf } from '@noble/hashes/hkdf'; -import { sha256 } from '@noble/hashes/sha2'; +import { areUint8ArraysEqual, base64ToBytes } from '@metamask/utils'; +import nacl from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding'; +import { toBase64Url } from './encoding'; import { wrapEncryptionKey } from './wrapEncryptionKey'; const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); /** - * Reverses {@link wrapEncryptionKey} from the server's perspective: derives the - * same shared secret from the server private key + client public key and - * decrypts. + * Reverses {@link wrapEncryptionKey} from the server's perspective: opens the + * NaCl box using the server private key + client public key. * * @param serverPrivateKey - The server's X25519 private key. * @param clientPublicKey - The client's X25519 public key. @@ -26,38 +22,41 @@ function unwrap( encryptedKey: string, nonce: string, ): Uint8Array { - const shared = x25519.getSharedSecret(serverPrivateKey, clientPublicKey); - const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); - return gcm(aeadKey, base64UrlToBytes(nonce)).decrypt( - base64UrlToBytes(encryptedKey), + const recovered = nacl.box.open( + base64ToBytes(encryptedKey), + base64ToBytes(nonce), + clientPublicKey, + serverPrivateKey, ); + if (recovered === null) { + throw new Error('Failed to open NaCl box'); + } + return recovered; } describe('UKYC wrapEncryptionKey', () => { it('wraps a key the session server can recover', () => { - const serverPrivateKey = x25519.utils.randomSecretKey(); - const serverPublicKey = x25519.getPublicKey(serverPrivateKey); - const clientPrivateKey = x25519.utils.randomSecretKey(); - const clientPublicKey = x25519.getPublicKey(clientPrivateKey); + const serverKeyPair = nacl.box.keyPair(); + const clientKeyPair = nacl.box.keyPair(); const { encryptedKey, nonce } = wrapEncryptionKey( - clientPrivateKey, - toBase64Url(serverPublicKey), + clientKeyPair.secretKey, + toBase64Url(serverKeyPair.publicKey), DATA_ENCRYPTION_KEY, ); const recovered = unwrap( - serverPrivateKey, - clientPublicKey, + serverKeyPair.secretKey, + clientKeyPair.publicKey, encryptedKey, nonce, ); expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); }); - it('emits unpadded base64url fields', () => { - const serverPublicKey = x25519.getPublicKey(x25519.utils.randomSecretKey()); - const clientPrivateKey = x25519.utils.randomSecretKey(); + it('emits standard base64 fields', () => { + const serverPublicKey = nacl.box.keyPair().publicKey; + const clientPrivateKey = nacl.box.keyPair().secretKey; const { encryptedKey, nonce } = wrapEncryptionKey( clientPrivateKey, @@ -65,13 +64,13 @@ describe('UKYC wrapEncryptionKey', () => { DATA_ENCRYPTION_KEY, ); - expect(encryptedKey).toMatch(/^[A-Za-z0-9_-]+$/u); - expect(nonce).toMatch(/^[A-Za-z0-9_-]+$/u); + expect(encryptedKey).toMatch(/^[A-Za-z0-9+/]+={0,2}$/u); + expect(nonce).toMatch(/^[A-Za-z0-9+/]+={0,2}$/u); }); it('uses a fresh nonce per call', () => { - const serverPublicKey = x25519.getPublicKey(x25519.utils.randomSecretKey()); - const clientPrivateKey = x25519.utils.randomSecretKey(); + const serverPublicKey = nacl.box.keyPair().publicKey; + const clientPrivateKey = nacl.box.keyPair().secretKey; const serverPublicKeyB64 = toBase64Url(serverPublicKey); const first = wrapEncryptionKey( diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts index fad46237512..1104b8d8856 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -1,14 +1,12 @@ -import { gcm } from '@noble/ciphers/aes'; -import { x25519 } from '@noble/curves/ed25519'; -import { hkdf } from '@noble/hashes/hkdf'; -import { sha256 } from '@noble/hashes/sha2'; -import { randomBytes } from '@noble/hashes/utils'; +import { bytesToBase64 } from '@metamask/utils'; +import nacl from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding'; +import { base64UrlToBytes } from './encoding'; /** - * Wraps the `data_encryption_key` for the UKYC session server using the - * static-static ECDH established with the server's per-session wrapping key. + * Wraps the `data_encryption_key` for the UKYC session server using NaCl's + * `crypto_box` (X25519 + XSalsa20-Poly1305) established with the server's + * per-session wrapping key. * * Unlike {@link wrapUserKey} (which generates a fresh ephemeral keypair per * call), this uses the session client keypair whose public half was already @@ -17,13 +15,10 @@ import { base64UrlToBytes, toBase64Url } from './encoding'; * private key, so only `{ encryptedKey, nonce }` need be transmitted. */ -/** 96-bit nonce, the AES-GCM standard nonce size. */ -const NONCE_SIZE_BYTES = 12; - /** - * The transmitted portion of a wrapped encryption key: the AES-256-GCM - * ciphertext (which includes the 16-byte auth tag) and the nonce, both - * base64url-encoded. + * The transmitted portion of a wrapped encryption key: the `crypto_box` + * ciphertext (which includes the 16-byte Poly1305 auth tag) and the nonce, + * both standard base64-encoded. */ export type WrappedEncryptionKeyParts = { encryptedKey: string; @@ -33,14 +28,14 @@ export type WrappedEncryptionKeyParts = { /** * Wraps `keyToWrap` for the UKYC session server. * - * The AEAD key is the ECDH shared secret between our session client private key - * and the session server public key returned by `getWrappingKey`, run through - * HKDF-SHA256; the key is then sealed with AES-256-GCM. + * The box is sealed with NaCl's `crypto_box`, keyed by the X25519 shared secret + * between our session client private key and the session server public key + * returned by `getWrappingKey`. * * @param sessionClientPrivateKey - Our session's X25519 private key. * @param sessionServerPublicKey - The server's X25519 public key (base64url). * @param keyToWrap - The raw symmetric key bytes to encrypt. - * @returns The base64url `encryptedKey` (ciphertext + tag) and `nonce`. + * @returns The base64 `encryptedKey` (ciphertext + tag) and `nonce`. */ export function wrapEncryptionKey( sessionClientPrivateKey: Uint8Array, @@ -48,15 +43,15 @@ export function wrapEncryptionKey( keyToWrap: Uint8Array, ): WrappedEncryptionKeyParts { const serverPublicKey = base64UrlToBytes(sessionServerPublicKey); - const shared = x25519.getSharedSecret( - sessionClientPrivateKey, + const nonce = nacl.randomBytes(nacl.box.nonceLength); + const encryptedKey = nacl.box( + keyToWrap, + nonce, serverPublicKey, + sessionClientPrivateKey, ); - const aeadKey = hkdf(sha256, shared, undefined, undefined, 32); - const nonce = randomBytes(NONCE_SIZE_BYTES); - const encryptedKey = gcm(aeadKey, nonce).encrypt(keyToWrap); return { - encryptedKey: toBase64Url(encryptedKey), - nonce: toBase64Url(nonce), + encryptedKey: bytesToBase64(encryptedKey), + nonce: bytesToBase64(nonce), }; } diff --git a/yarn.lock b/yarn.lock index 949feb617aa..bd167510aa2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7670,6 +7670,7 @@ __metadata: reselect: "npm:^5.1.1" ts-jest: "npm:^29.2.5" tsx: "npm:^4.20.5" + tweetnacl: "npm:^1.0.3" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" From 966ca62bc34ed59e83801642f5c1cb1dbfca7386 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Wed, 29 Jul 2026 13:05:02 -0700 Subject: [PATCH 24/67] Fix key wrapping. Fix idosSessionId param --- .../kyc-controller/src/KycController.test.ts | 2 -- packages/kyc-controller/src/KycController.ts | 9 ++---- .../src/KycService-method-action-types.ts | 4 +-- .../kyc-controller/src/KycService.test.ts | 32 +++++++++++-------- packages/kyc-controller/src/KycService.ts | 25 ++++++--------- packages/kyc-controller/src/index.ts | 1 - .../src/ukyc/wrapEncryptionKey.test.ts | 14 ++++---- .../src/ukyc/wrapEncryptionKey.ts | 11 +++---- 8 files changed, 45 insertions(+), 53 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index b2feaf9d84f..1e2c3057412 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1332,7 +1332,6 @@ describe('KycController', () => { controller.reset(); return { sessionId: 'sid', - idosSessionId: 'iss', }; }); @@ -1550,7 +1549,6 @@ function withController( fetchJwks: jest.fn().mockResolvedValue({ keys: [] }), createUkycSession: jest.fn().mockResolvedValue({ sessionId: 'sid', - idosSessionId: 'iss', }), fetchApplicantAccessToken: jest .fn() diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index cf0b43a6cd4..ebb899c312e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1055,7 +1055,7 @@ export class KycController extends BaseController< expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), }); - const { sessionId, idosSessionId } = await this.messenger.call( + const { sessionId } = await this.messenger.call( 'KycService:createUkycSession', { jwtToken, @@ -1068,9 +1068,6 @@ export class KycController extends BaseController< }, ); - // Retain the session identifiers so the SDK can refresh its token. - const exchange = { sessionId, idosSessionId }; - this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'fetchingToken'; state.sumsub.sessionId = sessionId; @@ -1078,7 +1075,7 @@ export class KycController extends BaseController< const { applicantAccessToken } = await this.messenger.call( 'KycService:fetchApplicantAccessToken', - exchange, + sessionId, ); // A reset() may have landed while the session/token was being prepared. @@ -1113,7 +1110,7 @@ export class KycController extends BaseController< } const refreshed = await this.messenger.call( 'KycService:fetchApplicantAccessToken', - exchange, + sessionId, ); return refreshed.applicantAccessToken; }, diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 6ae4909d3e7..3252ca944d0 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -103,9 +103,7 @@ export type KycServiceCreateUkycSessionAction = { * Fetches (or refreshes) the SumSub applicant access token for a UKYC * session. * - * @param params - The parameters. - * @param params.sessionId - The UKYC session id from `createUkycSession`. - * @param params.idosSessionId - The idOS session id from `createUkycSession`. + * @param sessionId - The UKYC session id from `createUkycSession`. * @returns The applicant access token and status. */ export type KycServiceFetchApplicantAccessTokenAction = { diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 228418b9c95..985a7bda2b3 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -286,7 +286,6 @@ describe('KycService', () => { it('creates a UKYC session and forwards the wrapped key and capability token', async () => { const response = { sessionId: 'sid', - idosSessionId: 'iss', }; nock(MOCK_API_URL) .post( @@ -309,7 +308,7 @@ describe('KycService', () => { }); it('throws on a malformed response', async () => { - nock(MOCK_API_URL).post('/sessions').reply(200, { sessionId: 'sid' }); + nock(MOCK_API_URL).post('/sessions').reply(200, { unexpected: true }); const { service } = getService(); await expect( @@ -327,16 +326,26 @@ describe('KycService', () => { it('fetches the applicant access token for a session', async () => { const response = { status: 'ok', applicantAccessToken: 'aat' }; nock(MOCK_API_URL) - .post('/sessions/sid/wrapped-key', { idosSessionId: 'iss' }) + .post('/sessions/sid/wrapped-key') .reply(200, response); const { service } = getService(); - expect( - await service.fetchApplicantAccessToken({ - sessionId: 'sid', - idosSessionId: 'iss', - }), - ).toStrictEqual(response); + expect(await service.fetchApplicantAccessToken('sid')).toStrictEqual( + response, + ); + }); + + it('does not send a Content-Type header since it has no body', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL) + .post('/sessions/sid/wrapped-key') + .matchHeader('content-type', (value) => value === undefined) + .reply(200, response); + const { service } = getService(); + + expect(await service.fetchApplicantAccessToken('sid')).toStrictEqual( + response, + ); }); it('throws on a malformed response', async () => { @@ -346,10 +355,7 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchApplicantAccessToken({ - sessionId: 'sid', - idosSessionId: 'iss', - }), + service.fetchApplicantAccessToken('sid'), ).rejects.toThrow(/Malformed response received from wrapped-key API/u); }); }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index b7b0e2d1009..c3b175473c4 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -150,7 +150,6 @@ export type JwksResponse = Infer; const UkycSessionResponseStruct = type({ sessionId: string(), - idosSessionId: string(), }); export type UkycSessionResponse = Infer; @@ -206,11 +205,6 @@ export type CreateUkycSessionParams = { ukycCapabilityToken: UkycStorageAccessToken; }; -export type FetchApplicantAccessTokenParams = { - sessionId: string; - idosSessionId: string; -}; - // === SERVICE DEFINITION === /** @@ -461,21 +455,18 @@ export class KycService { * Fetches (or refreshes) the SumSub applicant access token for a UKYC * session. * - * @param params - The parameters. - * @param params.sessionId - The UKYC session id from `createUkycSession`. - * @param params.idosSessionId - The idOS session id from `createUkycSession`. + * @param sessionId - The UKYC session id from `createUkycSession`. * @returns The applicant access token and status. */ async fetchApplicantAccessToken( - params: FetchApplicantAccessTokenParams, + sessionId: string, ): Promise { const url = new URL( - `/sessions/${encodeURIComponent(params.sessionId)}/wrapped-key`, + `/sessions/${encodeURIComponent(sessionId)}/wrapped-key`, this.#baseUrl, ); const data = await this.#request(url, { method: 'POST', - body: JSON.stringify({ idosSessionId: params.idosSessionId }), }); return this.#validateResponse( data, @@ -541,9 +532,13 @@ export class KycService { ): Promise { const { authenticated = true } = options; - const headers: Record = { - 'Content-Type': 'application/json', - }; + const headers: Record = {}; + + // Only advertise a JSON body when one is actually sent; bodyless requests + // (e.g. `fetchApplicantAccessToken`) must not carry a `Content-Type`. + if (init.body !== undefined && init.body !== null) { + headers['Content-Type'] = 'application/json'; + } if (authenticated) { const bearerToken = await this.#messenger.call( diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 5e57f78561d..1e218a310c4 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -33,7 +33,6 @@ export type { CheckKycRequiredParams, CreateSessionParams, CreateUkycSessionParams, - FetchApplicantAccessTokenParams, GetWrappingKeyParams, JwksResponse, KycServiceActions, diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts index 020e5cbc697..96a2b507baf 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -1,7 +1,7 @@ -import { areUint8ArraysEqual, base64ToBytes } from '@metamask/utils'; +import { areUint8ArraysEqual } from '@metamask/utils'; import nacl from 'tweetnacl'; -import { toBase64Url } from './encoding'; +import { base64UrlToBytes, toBase64Url } from './encoding'; import { wrapEncryptionKey } from './wrapEncryptionKey'; const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); @@ -23,8 +23,8 @@ function unwrap( nonce: string, ): Uint8Array { const recovered = nacl.box.open( - base64ToBytes(encryptedKey), - base64ToBytes(nonce), + base64UrlToBytes(encryptedKey), + base64UrlToBytes(nonce), clientPublicKey, serverPrivateKey, ); @@ -54,7 +54,7 @@ describe('UKYC wrapEncryptionKey', () => { expect(areUint8ArraysEqual(recovered, DATA_ENCRYPTION_KEY)).toBe(true); }); - it('emits standard base64 fields', () => { + it('emits base64url fields', () => { const serverPublicKey = nacl.box.keyPair().publicKey; const clientPrivateKey = nacl.box.keyPair().secretKey; @@ -64,8 +64,8 @@ describe('UKYC wrapEncryptionKey', () => { DATA_ENCRYPTION_KEY, ); - expect(encryptedKey).toMatch(/^[A-Za-z0-9+/]+={0,2}$/u); - expect(nonce).toMatch(/^[A-Za-z0-9+/]+={0,2}$/u); + expect(encryptedKey).toMatch(/^[A-Za-z0-9\-_]+$/u); + expect(nonce).toMatch(/^[A-Za-z0-9\-_]+$/u); }); it('uses a fresh nonce per call', () => { diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts index 1104b8d8856..4a84f0256f2 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -1,7 +1,6 @@ -import { bytesToBase64 } from '@metamask/utils'; import nacl from 'tweetnacl'; -import { base64UrlToBytes } from './encoding'; +import { base64UrlToBytes, toBase64Url } from './encoding'; /** * Wraps the `data_encryption_key` for the UKYC session server using NaCl's @@ -18,7 +17,7 @@ import { base64UrlToBytes } from './encoding'; /** * The transmitted portion of a wrapped encryption key: the `crypto_box` * ciphertext (which includes the 16-byte Poly1305 auth tag) and the nonce, - * both standard base64-encoded. + * both unpadded base64url-encoded. */ export type WrappedEncryptionKeyParts = { encryptedKey: string; @@ -35,7 +34,7 @@ export type WrappedEncryptionKeyParts = { * @param sessionClientPrivateKey - Our session's X25519 private key. * @param sessionServerPublicKey - The server's X25519 public key (base64url). * @param keyToWrap - The raw symmetric key bytes to encrypt. - * @returns The base64 `encryptedKey` (ciphertext + tag) and `nonce`. + * @returns The base64url `encryptedKey` (ciphertext + tag) and `nonce`. */ export function wrapEncryptionKey( sessionClientPrivateKey: Uint8Array, @@ -51,7 +50,7 @@ export function wrapEncryptionKey( sessionClientPrivateKey, ); return { - encryptedKey: bytesToBase64(encryptedKey), - nonce: bytesToBase64(nonce), + encryptedKey: toBase64Url(encryptedKey), + nonce: toBase64Url(nonce), }; } From 8eee01ddbdf5152231f858ba9d4c118312273f78 Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 30 Jul 2026 14:58:18 -0700 Subject: [PATCH 25/67] rename fetchApplicantAccessToken to createJourney --- packages/kyc-controller/README.md | 12 +++++----- .../src/KycService-method-action-types.ts | 13 ++++++----- .../kyc-controller/src/KycService.test.ts | 23 +++++++++---------- packages/kyc-controller/src/KycService.ts | 14 +++++------ 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index d53a7629622..55a9ff27473 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -145,7 +145,7 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): `getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, -`createUkycSession`, `submitWrappedKey`. +`createUkycSession`, `createJourney`. Endpoints: @@ -156,7 +156,7 @@ Endpoints: | `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | | `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | | `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | -| `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | +| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | ### 2.3 `crypto.ts` @@ -356,8 +356,8 @@ sequenceDiagram opt kycRequired === true → auto-launch document verification Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken }) Svc->>API: POST /sessions - Ctrl->>Svc: submitWrappedKey({ sessionId, wrappedUserKey, ... }) - Svc->>API: POST /sessions/{id}/wrapped-key + Ctrl->>Svc: createJourney(sessionId) + Svc->>API: POST /sessions/{id}/journey Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) Launcher-->>Ctrl: SDK result Ctrl-->>UI: sumsub.status = complete (+ result) @@ -444,7 +444,7 @@ stateDiagram-v2 [*] --> idle idle --> creatingSession : startSumSub() creatingSession --> fetchingToken : createUkycSession() ok - fetchingToken --> launching : submitWrappedKey() ok + fetchingToken --> launching : createJourney() ok launching --> inProgress : onStatusChange (non-Completed) launching --> complete : onStatusChange = Completed inProgress --> complete : onStatusChange = Completed @@ -472,7 +472,7 @@ type KycSumSubLauncher = { ``` `launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the -controller re-runs `submitWrappedKey` to refresh — but **refuses to refresh +controller re-runs `createJourney` to refresh — but **refuses to refresh after a `reset()`**, throwing instead so a still-open SDK cannot keep an orphaned UKYC session alive), and an `onStatusChange` callback that the controller maps into `sumsub.status`. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 3252ca944d0..4bf6b3a9774 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -100,15 +100,16 @@ export type KycServiceCreateUkycSessionAction = { }; /** - * Fetches (or refreshes) the SumSub applicant access token for a UKYC - * session. + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. * * @param sessionId - The UKYC session id from `createUkycSession`. * @returns The applicant access token and status. */ -export type KycServiceFetchApplicantAccessTokenAction = { - type: `KycService:fetchApplicantAccessToken`; - handler: KycService['fetchApplicantAccessToken']; +export type KycServiceCreateJourneyAction = { + type: `KycService:createJourney`; + handler: KycService['createJourney']; +}; }; /** @@ -122,4 +123,4 @@ export type KycServiceMethodActions = | KycServiceGetWrappingKeyAction | KycServiceFetchJwksAction | KycServiceCreateUkycSessionAction - | KycServiceFetchApplicantAccessTokenAction; + | KycServiceCreateJourneyAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 985a7bda2b3..98fadb7df81 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -322,38 +322,37 @@ describe('KycService', () => { }); }); - describe('fetchApplicantAccessToken', () => { + describe('createJourney', () => { it('fetches the applicant access token for a session', async () => { const response = { status: 'ok', applicantAccessToken: 'aat' }; - nock(MOCK_API_URL) - .post('/sessions/sid/wrapped-key') - .reply(200, response); + nock(MOCK_API_URL).post('/sessions/sid/journey').reply(200, response); const { service } = getService(); - expect(await service.fetchApplicantAccessToken('sid')).toStrictEqual( - response, - ); + expect(await service.createJourney('sid')).toStrictEqual(response); }); it('does not send a Content-Type header since it has no body', async () => { const response = { status: 'ok', applicantAccessToken: 'aat' }; nock(MOCK_API_URL) - .post('/sessions/sid/wrapped-key') + .post('/sessions/sid/journey') .matchHeader('content-type', (value) => value === undefined) .reply(200, response); const { service } = getService(); - expect(await service.fetchApplicantAccessToken('sid')).toStrictEqual( - response, - ); + expect(await service.createJourney('sid')).toStrictEqual(response); }); it('throws on a malformed response', async () => { nock(MOCK_API_URL) - .post('/sessions/sid/wrapped-key') + .post('/sessions/sid/journey') .reply(200, { status: 'ok' }); const { service } = getService(); + await expect(service.createJourney('sid')).rejects.toThrow( + /Malformed response received from journey API/u, + ); + }); + }); await expect( service.fetchApplicantAccessToken('sid'), ).rejects.toThrow(/Malformed response received from wrapped-key API/u); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index c3b175473c4..28c66c27a37 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -49,7 +49,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getWrappingKey', 'fetchJwks', 'createUkycSession', - 'fetchApplicantAccessToken', + 'createJourney', ] as const; /** @@ -452,17 +452,17 @@ export class KycService { } /** - * Fetches (or refreshes) the SumSub applicant access token for a UKYC - * session. + * Creates (or refreshes) the SumSub verification journey for a UKYC session, + * returning the applicant access token used to launch the SDK. * * @param sessionId - The UKYC session id from `createUkycSession`. * @returns The applicant access token and status. */ - async fetchApplicantAccessToken( + async createJourney( sessionId: string, ): Promise { const url = new URL( - `/sessions/${encodeURIComponent(sessionId)}/wrapped-key`, + `/sessions/${encodeURIComponent(sessionId)}/journey`, this.#baseUrl, ); const data = await this.#request(url, { @@ -471,7 +471,7 @@ export class KycService { return this.#validateResponse( data, ApplicantAccessTokenResponseStruct, - 'wrapped-key', + 'journey', ); } @@ -535,7 +535,7 @@ export class KycService { const headers: Record = {}; // Only advertise a JSON body when one is actually sent; bodyless requests - // (e.g. `fetchApplicantAccessToken`) must not carry a `Content-Type`. + // (e.g. `createJourney`) must not carry a `Content-Type`. if (init.body !== undefined && init.body !== null) { headers['Content-Type'] = 'application/json'; } From 40bb4355c9497fefb55921f87bd56984d12446be Mon Sep 17 00:00:00 2001 From: Jiexi Luan Date: Thu, 30 Jul 2026 14:58:43 -0700 Subject: [PATCH 26/67] polling status --- packages/kyc-controller/CHANGELOG.md | 5 + .../src/KycController-method-action-types.ts | 17 +- .../kyc-controller/src/KycController.test.ts | 295 +++++++++++++++++- packages/kyc-controller/src/KycController.ts | 220 ++++++++++++- .../src/KycService-method-action-types.ts | 18 +- .../kyc-controller/src/KycService.test.ts | 54 +++- packages/kyc-controller/src/KycService.ts | 42 ++- packages/kyc-controller/src/index.ts | 6 +- packages/kyc-controller/src/types.ts | 29 ++ 9 files changed, 660 insertions(+), 26 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 29f5a436616..a8d3fa3569c 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -17,5 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state. - When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior). - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#9615](https://github.com/MetaMask/core/pull/9615)) +- Add UKYC session-status polling to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) + - After the SumSub SDK reports completion, the controller now polls the UKYC backend for the session's final verification decision instead of treating the SDK result as final. Polling stops on a terminal `finalStatus` (`approved`, `completed`, `rejected`, `failed`, `blocked`), resolving the sub-flow to `complete` (for `approved` / `completed`) or `failed` (otherwise). Polling is also cleared on `reset` and when a new sub-flow starts. + - Add a new `polling` value to `KycSumSubStatus` and a new `sumsub.sessionStatus` field (typed as the new `KycSessionStatus`) that holds the latest polled status. + - Add `KycController.getSessionStatus` for a one-off session-status fetch, and add an optional `sessionStatusPollIntervalMs` constructor option (defaults to 15000ms). + - Add `KycService.getSessionStatus`, backed by the `GET /sessions/{id}/status` endpoint. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 9b81dc9c078..941da3a5dca 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -134,7 +134,8 @@ export type KycControllerGetKycStatusAction = { * attested session server public key; * 3. derives the `data_encryption_key` from the wallet's UKYC * `local_user_secret` and wraps it for the session server; - * 4. creates the UKYC session (handing over the wrapped key); + * 4. mints a client-signed, read-only `ukyc_capability_token` and creates + * the UKYC session (handing over the wrapped key and the token); * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * @@ -148,6 +149,19 @@ export type KycControllerStartSumSubAction = { handler: KycController['startSumSub']; }; +/** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ +export type KycControllerGetSessionStatusAction = { + type: `KycController:getSessionStatus`; + handler: KycController['getSessionStatus']; +}; + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. @@ -172,4 +186,5 @@ export type KycControllerMethodActions = | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction | KycControllerStartSumSubAction + | KycControllerGetSessionStatusAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 1e2c3057412..fea2fbca55e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1257,7 +1257,7 @@ describe('KycController', () => { }), ); // onTokenExpiration re-fetches the applicant access token. - expect(handlers.fetchApplicantAccessToken).toHaveBeenCalledTimes(2); + expect(handlers.createJourney).toHaveBeenCalledTimes(2); }); }); @@ -1350,7 +1350,7 @@ describe('KycController', () => { await withController(async ({ controller, handlers, launcher }) => { // A reset() lands during the final token exchange, i.e. after the // session is prepared but before the SDK is presented. - handlers.fetchApplicantAccessToken.mockImplementation(async () => { + handlers.createJourney.mockImplementation(async () => { controller.reset(); return { status: 'ok', applicantAccessToken: 'aat' }; }); @@ -1373,17 +1373,17 @@ describe('KycController', () => { launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { // The SDK stays open across a reset, then asks for a fresh token. controller.reset(); - // Only the initial fetchApplicantAccessToken (session setup) should + // Only the initial createJourney (session setup) should // have run. const callsBeforeRefresh = - handlers.fetchApplicantAccessToken.mock.calls.length; + handlers.createJourney.mock.calls.length; try { await onTokenExpiration(); } catch (error) { refreshError = error; } // The refresh must not hit the stale UKYC session. - expect(handlers.fetchApplicantAccessToken.mock.calls).toHaveLength( + expect(handlers.createJourney.mock.calls).toHaveLength( callsBeforeRefresh, ); return { ok: true }; @@ -1417,6 +1417,252 @@ describe('KycController', () => { }); }); + describe('session status polling', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Makes the launcher report a successful SDK completion so the sub-flow + * proceeds into session-status polling. + * + * @param launcher - The mocked launcher. + */ + function completeSdk(launcher: Launcher): void { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + } + + it('polls the session status after completion and completes on an approved status', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }); + }); + + it('maps a rejected terminal status to a failed sub-flow', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected')); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('rejected'), + ); + }); + }); + + it('treats SDK completion as final when the UKYC session has no id to poll', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A session created without an id leaves nothing to poll against. + handlers.createUkycSession.mockResolvedValue({ sessionId: '' }); + completeSdk(launcher); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('does not poll when the SDK did not report completion', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant abandons the flow: `launch` resolves without ever + // reporting a Completed status. + launcher.launch.mockResolvedValue({ ok: false }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + }); + }); + + it('keeps polling on a transient error, preserving the last good status', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValueOnce(sessionStatus('approved')); + + await controller.startSumSub(); + + // First poll: non-terminal, keeps polling. + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Second poll fails transiently: the last good status is preserved + // and the loop keeps going. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Third poll reaches a terminal status. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); + }, + ); + }); + + it('stops polling once a terminal status is reached', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // No further polls after a terminal status. + await jest.advanceTimersByTimeAsync(5000); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('stops polling when reset() is called', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + controller.reset(); + await jest.advanceTimersByTimeAsync(5000); + + // The scheduled poll was cancelled by reset(). + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('discards a poll result when reset() runs while the request is in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // Simulate a reset() landing while the status request is in flight. + handlers.getSessionStatus.mockImplementation(async () => { + controller.reset(); + return sessionStatus('approved'); + }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionStatus).toBeNull(); + }); + }); + + it('supersedes a prior polling loop when a new sub-flow starts', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // First sub-flow polls a never-terminal status. + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // A second sub-flow reaches a terminal status on its first poll and + // must cancel the first loop's scheduled poll. + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + await controller.startSumSub(); + expect(controller.state.sumsub.status).toBe('complete'); + + const callsAfterSecondFlow = + handlers.getSessionStatus.mock.calls.length; + await jest.advanceTimersByTimeAsync(5000); + + // No stray polls from the superseded first loop. + expect(handlers.getSessionStatus).toHaveBeenCalledTimes( + callsAfterSecondFlow, + ); + }, + ); + }); + }); + + describe('getSessionStatus', () => { + it('fetches and records the session status on demand', async () => { + await withController( + { + options: { + state: { + sumsub: { + status: 'complete', + result: null, + sessionId: 'sid', + applicantAccessToken: null, + sessionStatus: null, + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + const result = await controller.getSessionStatus(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }, + ); + }); + + it('throws when there is no active SumSub session', async () => { + await withController(async ({ controller }) => { + await expect(controller.getSessionStatus()).rejects.toThrow( + /no active SumSub session/u, + ); + }); + }); + }); + describe('reset', () => { it('clears session state but preserves persisted terms', async () => { await withController( @@ -1471,7 +1717,8 @@ type ServiceHandlers = { getWrappingKey: jest.Mock; fetchJwks: jest.Mock; createUkycSession: jest.Mock; - fetchApplicantAccessToken: jest.Mock; + createJourney: jest.Mock; + getSessionStatus: jest.Mock; performGetStorage: jest.Mock; performSetStorage: jest.Mock; }; @@ -1500,11 +1747,34 @@ const SERVICE_ACTIONS = [ 'KycService:getWrappingKey', 'KycService:fetchJwks', 'KycService:createUkycSession', - 'KycService:fetchApplicantAccessToken', + 'KycService:createJourney', + 'KycService:getSessionStatus', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; +/** + * Builds a UKYC session status payload with a given `finalStatus`. + * + * @param finalStatus - The overall session status. + * @returns A complete session status object. + */ +function sessionStatus(finalStatus: string): { + finalStatus: string; + externalUserId: string; + kycStatus: string; + vendor: string; + vendorStatus: string; +} { + return { + finalStatus, + externalUserId: 'ext-1', + kycStatus: finalStatus, + vendor: 'sumsub', + vendorStatus: finalStatus, + }; +} + /** * Wraps a test with a fully-wired controller, mocked service handlers, and a * mocked SumSub launcher. @@ -1550,9 +1820,10 @@ function withController( createUkycSession: jest.fn().mockResolvedValue({ sessionId: 'sid', }), - fetchApplicantAccessToken: jest + createJourney: jest .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), performGetStorage: jest.fn().mockResolvedValue(null), performSetStorage: jest.fn().mockResolvedValue(undefined), }; @@ -1582,8 +1853,12 @@ function withController( handlers.createUkycSession, ); rootMessenger.registerActionHandler( - 'KycService:fetchApplicantAccessToken', - handlers.fetchApplicantAccessToken, + 'KycService:createJourney', + handlers.createJourney, + ); + rootMessenger.registerActionHandler( + 'KycService:getSessionStatus', + handlers.getSessionStatus, ); rootMessenger.registerActionHandler( 'UserStorageController:performGetStorage', diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index ebb899c312e..2a5b6e2ccde 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -20,6 +20,7 @@ import type { KycDisclaimer, KycPhase, KycProduct, + KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, } from './types'; @@ -68,6 +69,26 @@ const IN_PROGRESS_PHASES: KycPhase[] = [ 'submit', ]; +// How often to poll the UKYC session status after the SumSub SDK completes, +// until a terminal status is reached. Overridable via the constructor. +const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; + +// `finalStatus` values that end the polling loop. Anything else keeps polling. +const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ + 'approved', + 'completed', + 'rejected', + 'failed', + 'blocked', +]); + +// Terminal `finalStatus` values that represent a successful verification. Any +// other terminal status resolves the sub-flow to `failed`. +const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ + 'approved', + 'completed', +]); + // === STATE === /** @@ -124,6 +145,11 @@ export type KycControllerState = { result: Json | null; sessionId: string | null; applicantAccessToken: string | null; + /** + * The latest UKYC session status, populated while polling after the SDK + * completes. `null` until the first successful poll. + */ + sessionStatus: KycSessionStatus | null; }; }; @@ -253,6 +279,7 @@ export function getDefaultKycControllerState(): KycControllerState { result: null, sessionId: null, applicantAccessToken: null, + sessionStatus: null, }, }; } @@ -271,6 +298,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkKycRequired', 'getKycStatus', 'startSumSub', + 'getSessionStatus', 'reset', ] as const; @@ -314,6 +342,12 @@ export type KycControllerOptions = { * the controller stays platform-agnostic. */ sumsubLauncher: KycSumSubLauncher; + /** + * How often, in milliseconds, to poll the UKYC session status after the + * SumSub SDK completes. Defaults to + * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. + */ + sessionStatusPollIntervalMs?: number; }; /** @@ -364,6 +398,21 @@ export class KycController extends BaseController< */ #generation = 0; + /** Interval, in milliseconds, between session-status polls. */ + readonly #sessionStatusPollIntervalMs: number; + + /** Handle for the scheduled next session-status poll, or `null`. */ + #pollTimer: ReturnType | null = null; + + /** + * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a + * new sub-flow, and once a terminal status is reached) so an in-flight poll + * `tick` can detect it was superseded and neither write state nor schedule a + * follow-up. This closes the gap where clearing the timer alone would still + * let an already-awaiting request finish and reschedule. + */ + #pollToken = 0; + /** * Constructs a new {@link KycController}. * @@ -371,8 +420,15 @@ export class KycController extends BaseController< * @param options.messenger - The messenger suited for this controller. * @param options.state - Partial initial state; merged over defaults. * @param options.sumsubLauncher - The platform SumSub launcher adapter. + * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC + * session status after the SumSub SDK completes. */ - constructor({ messenger, state, sumsubLauncher }: KycControllerOptions) { + constructor({ + messenger, + state, + sumsubLauncher, + sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + }: KycControllerOptions) { super({ messenger, metadata: kycControllerMetadata, @@ -381,6 +437,7 @@ export class KycController extends BaseController< }); this.#sumsubLauncher = sumsubLauncher; + this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; this.#keypair = generateKeyPair(); this.messenger.registerMethodActionHandlers( @@ -981,6 +1038,9 @@ export class KycController extends BaseController< locale?: string; debug?: boolean; }): Promise> { + // A new sub-flow supersedes any polling still running from a prior run. + this.#stopPolling(); + if (!this.#sumsubLauncher.isAvailable()) { const error = 'SumSub SDK is not available in this runtime.'; this.#applyUpdate((state) => { @@ -999,6 +1059,7 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sumsub.status = 'creatingSession'; state.sumsub.result = null; + state.sumsub.sessionStatus = null; }); const jwtToken = MOCK_JWT_TOKEN; @@ -1074,7 +1135,7 @@ export class KycController extends BaseController< }); const { applicantAccessToken } = await this.messenger.call( - 'KycService:fetchApplicantAccessToken', + 'KycService:createJourney', sessionId, ); @@ -1109,7 +1170,7 @@ export class KycController extends BaseController< ); } const refreshed = await this.messenger.call( - 'KycService:fetchApplicantAccessToken', + 'KycService:createJourney', sessionId, ); return refreshed.applicantAccessToken; @@ -1127,13 +1188,29 @@ export class KycController extends BaseController< debug: params?.debug ?? false, }); - // Only record `complete` when the SDK actually reported completion; - // otherwise treat the resolved-but-unfinished flow as `failed` so - // consumers and UI do not mistake it for a finished verification. - this.#updateIfCurrent(generation, (state) => { - state.sumsub.status = reachedCompletion ? 'complete' : 'failed'; + // A resolved `launch` alone is not the final outcome: only a SDK-reported + // completion is worth polling for a verification decision. Anything else + // (abandonment, non-success) is `failed` and must not be polled. + const applied = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = reachedCompletion ? 'polling' : 'failed'; state.sumsub.result = result as Json; }); + + // Once the SDK completes, the authoritative verification decision comes + // from the UKYC backend, not the SDK result. Poll the session status + // until it reaches a terminal decision. Guard on `applied` so a `reset()` + // that landed during `launch` cannot start polling on an idle flow. + if (applied && reachedCompletion) { + if (sessionId) { + await this.#startSessionStatusPolling(sessionId); + } else { + // No session id to poll against; fall back to treating the SDK + // completion as the final outcome. + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + }); + } + } return result; } catch (error) { const result = { error: String(error) }; @@ -1145,12 +1222,138 @@ export class KycController extends BaseController< } } + /** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ + async getSessionStatus(): Promise { + const { sessionId } = this.state.sumsub; + if (!sessionId) { + throw new Error('Cannot fetch session status: no active SumSub session.'); + } + + // Capture the flow generation so a `reset()` landing while the request is + // in flight cannot write the result onto an idle controller. + const generation = this.#generation; + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.sessionStatus = sessionStatus; + }); + return sessionStatus; + } + + /** + * Begins polling the UKYC session status until a terminal decision is + * reached. The first poll runs immediately (and is awaited by + * {@link startSumSub}); subsequent polls are scheduled every + * `#sessionStatusPollIntervalMs`. + * + * @param sessionId - The UKYC session id to poll. + * @returns A promise that resolves once the first poll settles. + */ + async #startSessionStatusPolling(sessionId: string): Promise { + // Supersede any prior loop and claim a fresh token for this one. Because + // `#stopPolling` bumps the token, any in-flight poll from a previous loop + // sees a mismatch and neither writes state nor reschedules. + this.#stopPolling(); + const token = this.#pollToken; + + const tick = async (): Promise => { + const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); + if (shouldStop) { + return; + } + this.#pollTimer = setTimeout(() => { + this.#pollTimer = null; + // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and + // therefore never rejects, so this fire-and-forget scheduled poll + // cannot surface as an unhandled rejection. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#sessionStatusPollIntervalMs); + }; + + await tick(); + } + + /** + * Performs a single session-status poll: fetches the status, records it, and + * resolves the sub-flow when the status is terminal. + * + * Transient errors are swallowed so the loop keeps polling; the last good + * `sessionStatus` is deliberately preserved rather than being overwritten + * with the error. + * + * @param sessionId - The UKYC session id to poll. + * @param token - The polling token captured when the loop started. + * @returns `true` when the loop should stop (terminal status or superseded + * by a reset / new sub-flow), `false` when it should keep polling. + */ + async #pollSessionStatusOnce( + sessionId: string, + token: number, + ): Promise { + try { + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + // Superseded while the request was in flight — drop the result. + if (this.#pollToken !== token) { + return true; + } + const isTerminal = TERMINAL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ); + this.#applyUpdate((state) => { + state.sumsub.sessionStatus = sessionStatus; + if (isTerminal) { + state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ) + ? 'complete' + : 'failed'; + } + }); + if (isTerminal) { + this.#stopPolling(); + } + return isTerminal; + } catch { + // Keep polling on transient errors, preserving the last good status. + // Stop only when a reset / new sub-flow superseded this loop. + return this.#pollToken !== token; + } + } + + /** + * Stops the session-status polling loop: bumps the polling token (so any + * in-flight `tick` bows out) and clears any scheduled poll. + */ + #stopPolling(): void { + this.#pollToken += 1; + if (this.#pollTimer !== null) { + clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + } + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. */ reset(): void { this.#authClientToken = null; + // Stop any session-status polling so a late poll cannot write onto the + // now-idle controller. + this.#stopPolling(); // Invalidate any in-flight async work started before this reset so its // results are discarded rather than written onto the now-idle controller. this.#generation += 1; @@ -1169,6 +1372,7 @@ export class KycController extends BaseController< result: null, sessionId: null, applicantAccessToken: null, + sessionStatus: null, }; }); } diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 4bf6b3a9774..a1acc2b68c7 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -88,8 +88,9 @@ export type KycServiceFetchJwksAction = { /** * Creates a UKYC session for the SumSub document-verification sub-flow, - * handing over the wrapped `data_encryption_key` and a client-signed, - * read-only `ukyc_capability_token`. + * handing over the wrapped `data_encryption_key` and the client-signed, + * read-only `ukyc_capability_token` that authorizes later storage access for + * the session. * * @param params - The session parameters. * @returns The UKYC session identifiers. @@ -110,6 +111,18 @@ export type KycServiceCreateJourneyAction = { type: `KycService:createJourney`; handler: KycService['createJourney']; }; + +/** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ +export type KycServiceGetSessionStatusAction = { + type: `KycService:getSessionStatus`; + handler: KycService['getSessionStatus']; }; /** @@ -124,3 +137,4 @@ export type KycServiceMethodActions = | KycServiceFetchJwksAction | KycServiceCreateUkycSessionAction | KycServiceCreateJourneyAction + | KycServiceGetSessionStatusAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 98fadb7df81..0a0e19ccac8 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -353,9 +353,59 @@ describe('KycService', () => { ); }); }); + + describe('getSessionStatus', () => { + it('returns the session status', async () => { + const response = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + }; + nock(MOCK_API_URL).get('/sessions/sid/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'sid' }), + ).toStrictEqual(response); + }); + + it('url-encodes the session id', async () => { + const response = { + finalStatus: 'pending', + externalUserId: 'ext-1', + kycStatus: 'pending', + vendor: 'sumsub', + vendorStatus: 'YELLOW', + }; + nock(MOCK_API_URL).get('/sessions/a%2Fb/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'a/b' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(200, { finalStatus: 'approved' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/Malformed response received from session status API/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(404); + const { service } = getService(); + await expect( - service.fetchApplicantAccessToken('sid'), - ).rejects.toThrow(/Malformed response received from wrapped-key API/u); + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '404'/u); }); }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 28c66c27a37..159c4c45ea1 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -11,6 +11,7 @@ import { array, assert, boolean, + optional, string, StructError, type, @@ -18,7 +19,7 @@ import { import { alpha2ToAlpha3 } from './countryCodes'; import type { KycServiceMethodActions } from './KycService-method-action-types'; -import type { KycDisclaimer } from './types'; +import type { KycDisclaimer, KycSessionStatus } from './types'; import { encodeStorageAccessTokenForHeader, UKYC_JWKS_PATH } from './ukyc'; import type { UkycStorageAccessToken } from './ukyc'; @@ -50,6 +51,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchJwks', 'createUkycSession', 'createJourney', + 'getSessionStatus', ] as const; /** @@ -100,7 +102,7 @@ export type KycServiceOptions = { /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is - * fetched. Required to run the wrapped-key exchange in + * fetched. Required to run the wrapping-key exchange in * {@link KycService.fetchJwks}. */ fractalEncryptionBaseUrl?: string; @@ -161,6 +163,15 @@ export type ApplicantAccessTokenResponse = Infer< typeof ApplicantAccessTokenResponseStruct >; +const SessionStatusResponseStruct = type({ + finalStatus: string(), + statusMessage: optional(string()), + externalUserId: string(), + kycStatus: string(), + vendor: string(), + vendorStatus: string(), +}); + // === PARAM TYPES === export type CreateSessionParams = { @@ -205,6 +216,10 @@ export type CreateUkycSessionParams = { ukycCapabilityToken: UkycStorageAccessToken; }; +export type GetSessionStatusParams = { + sessionId: string; +}; + // === SERVICE DEFINITION === /** @@ -475,6 +490,29 @@ export class KycService { ); } + /** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ + async getSessionStatus( + params: GetSessionStatusParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/status`, + this.#baseUrl, + ); + const data = await this.#request(url, { method: 'GET' }); + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'session status', + ); + } + /** * Validates a parsed API response against a superstruct schema, throwing a * descriptive error when the response does not match. diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 1e218a310c4..8e5897d54fd 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -20,6 +20,7 @@ export type { KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, KycControllerGetKycStatusAction, + KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, KycControllerInitializeAction, KycControllerLoadDisclaimersAction, @@ -33,6 +34,7 @@ export type { CheckKycRequiredParams, CreateSessionParams, CreateUkycSessionParams, + GetSessionStatusParams, GetWrappingKeyParams, JwksResponse, KycServiceActions, @@ -46,12 +48,13 @@ export type { } from './KycService'; export type { KycServiceCheckKycRequiredAction, + KycServiceCreateJourneyAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, - KycServiceFetchApplicantAccessTokenAction, KycServiceFetchDisclaimersAction, KycServiceFetchJwksAction, KycServiceGetGeoCountryAction, + KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, } from './KycService-method-action-types'; @@ -74,6 +77,7 @@ export type { KycDisclaimer, KycPhase, KycProduct, + KycSessionStatus, KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubStatus, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 7861a293921..5026fbac1bf 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -46,6 +46,10 @@ export type KycPhase = /** * Progress of the SumSub document-verification sub-flow. + * + * - `polling` — the SDK finished and the controller is polling the UKYC + * backend for the session's final decision (see `KycSessionStatus`). The + * sub-flow resolves to `complete` or `failed` once a terminal status arrives. */ export type KycSumSubStatus = | 'idle' @@ -53,9 +57,34 @@ export type KycSumSubStatus = | 'fetchingToken' | 'launching' | 'inProgress' + | 'polling' | 'complete' | 'failed'; +/** + * The status of a UKYC session, returned by the `GET /sessions/{id}/status` + * endpoint and polled after the SumSub SDK completes to determine the final + * verification decision. + */ +export type KycSessionStatus = { + /** + * The overall status of the session. Terminal values (e.g. `approved`, + * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value + * keeps polling. + */ + finalStatus: string; + /** Optional human-readable message describing the status. */ + statusMessage?: string; + /** The vendor-agnostic external user id associated with the session. */ + externalUserId: string; + /** The KYC decision status. */ + kycStatus: string; + /** The identity vendor that handled the session. */ + vendor: string; + /** The vendor-specific status. */ + vendorStatus: string; +}; + /** * A single disclaimer/term the customer must accept before a session is * created. From 54b16fd364c79cc6cb23b963a95fe99fdd07c472 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 31 Jul 2026 22:47:09 +0200 Subject: [PATCH 27/67] feat: update lockfile after merge with main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- yarn.lock | 1201 +++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1159 insertions(+), 42 deletions(-) diff --git a/yarn.lock b/yarn.lock index 7634a83cf68..66b1f561858 100644 --- a/yarn.lock +++ b/yarn.lock @@ -476,7 +476,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -494,7 +494,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -517,7 +517,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" dependencies: @@ -731,7 +731,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/parser@npm:7.29.7" dependencies: @@ -921,7 +921,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -1020,7 +1020,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1867,7 +1867,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.29.7": +"@babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -1893,7 +1893,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -4949,6 +4949,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e + languageName: node + linkType: hard + "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -4990,6 +5004,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e + languageName: node + linkType: hard + "@jest/diff-sequences@npm:30.4.0": version: 30.4.0 resolution: "@jest/diff-sequences@npm:30.4.0" @@ -5030,6 +5085,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" + dependencies: + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 + languageName: node + linkType: hard + "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -5039,6 +5106,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee + languageName: node + linkType: hard + "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -5049,6 +5125,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -5063,6 +5149,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" + "@types/node": "npm:*" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 + languageName: node + linkType: hard + "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -5082,6 +5182,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 + languageName: node + linkType: hard + "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -5128,6 +5240,43 @@ __metadata: languageName: node linkType: hard +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc + languageName: node + linkType: hard + "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -5169,6 +5318,17 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb + languageName: node + linkType: hard + "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -5181,6 +5341,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 + languageName: node + linkType: hard + "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -5193,6 +5365,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 + languageName: node + linkType: hard + "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -5215,6 +5399,29 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + babel-plugin-istanbul: "npm:^6.1.1" + chalk: "npm:^4.0.0" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pirates: "npm:^4.0.4" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^4.0.2" + checksum: 10/30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 + languageName: node + linkType: hard + "@jest/types@npm:30.4.1": version: 30.4.1 resolution: "@jest/types@npm:30.4.1" @@ -6599,7 +6806,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -7430,6 +7637,17 @@ __metadata: languageName: unknown linkType: soft +"@metamask/geolocation-controller@npm:^0.1.3": + version: 0.1.3 + resolution: "@metamask/geolocation-controller@npm:0.1.3" + dependencies: + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.0.0" + "@metamask/messenger": "npm:^1.2.0" + checksum: 10/fbcde8af142027866dce845fb147b9ed55bda6edc475112f54d8f0cc197d54d277d67a547c035b3f4ab5f48b7ba1dd5b721c38c1025599cde65cb11a17d1f2da + languageName: node + linkType: hard + "@metamask/geolocation-controller@npm:^1.0.0, @metamask/geolocation-controller@workspace:packages/geolocation-controller": version: 0.0.0-use.local resolution: "@metamask/geolocation-controller@workspace:packages/geolocation-controller" @@ -7683,6 +7901,38 @@ __metadata: languageName: node linkType: hard +"@metamask/kyc-controller@workspace:packages/kyc-controller": + version: 0.0.0-use.local + resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^0.1.3" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + chokidar-cli: "npm:^3.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + tweetnacl: "npm:^1.0.3" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/local-node-utils@npm:^1.0.0, @metamask/local-node-utils@workspace:packages/local-node-utils": version: 0.0.0-use.local resolution: "@metamask/local-node-utils@workspace:packages/local-node-utils" @@ -10986,7 +11236,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -11158,7 +11408,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -11167,6 +11417,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e + languageName: node + linkType: hard + "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -11648,7 +11907,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -11680,7 +11939,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -11869,6 +12128,15 @@ __metadata: languageName: node linkType: hard +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "npm:*" + checksum: 10/79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 + languageName: node + linkType: hard + "@types/gtag.js@npm:^0.0.20": version: 0.0.20 resolution: "@types/gtag.js@npm:0.0.20" @@ -11966,6 +12234,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" + dependencies: + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -12282,7 +12560,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -13711,6 +13989,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -13732,6 +14017,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -13741,7 +14035,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -13762,7 +14056,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -13962,6 +14256,23 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" + dependencies: + "@jest/transform": "npm:^29.7.0" + "@types/babel__core": "npm:^7.1.14" + babel-plugin-istanbul: "npm:^6.1.1" + babel-preset-jest: "npm:^29.6.3" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.8.0 + checksum: 10/8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 + languageName: node + linkType: hard + "babel-loader@npm:^9.2.1": version: 9.2.1 resolution: "babel-loader@npm:9.2.1" @@ -13984,6 +14295,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-instrument: "npm:^5.0.4" + test-exclude: "npm:^6.0.0" + checksum: 10/ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d + languageName: node + linkType: hard + "babel-plugin-istanbul@npm:^7.0.1": version: 7.0.1 resolution: "babel-plugin-istanbul@npm:7.0.1" @@ -14006,6 +14330,18 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" + dependencies: + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.1.14" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 10/9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.14, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" @@ -14054,7 +14390,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.2.0": +"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.2.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -14091,6 +14427,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" + dependencies: + babel-plugin-jest-hoist: "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb + languageName: node + linkType: hard + "babel-runtime@npm:^6.26.0": version: 6.26.0 resolution: "babel-runtime@npm:6.26.0" @@ -14699,7 +15047,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -14853,7 +15201,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -14931,6 +15293,13 @@ __metadata: languageName: node linkType: hard +"cjs-module-lexer@npm:^1.0.0": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 + languageName: node + linkType: hard + "cjs-module-lexer@npm:^1.3.1": version: 1.4.0 resolution: "cjs-module-lexer@npm:1.4.0" @@ -15041,6 +15410,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -15130,13 +15510,22 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.2": +"collect-v8-coverage@npm:^1.0.0, collect-v8-coverage@npm:^1.0.2": version: 1.0.3 resolution: "collect-v8-coverage@npm:1.0.3" checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 languageName: node linkType: hard +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -15146,6 +15535,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -15545,6 +15941,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 + languageName: node + linkType: hard + "cron-parser@npm:^4.5.0": version: 4.9.0 resolution: "cron-parser@npm:4.9.0" @@ -15903,6 +16316,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" @@ -15928,6 +16348,18 @@ __metadata: languageName: node linkType: hard +"dedent@npm:^1.0.0": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" + peerDependencies: + babel-plugin-macros: ^3.1.0 + peerDependenciesMeta: + babel-plugin-macros: + optional: true + checksum: 10/30b9062290dca72b0f5a6cd3667633448cef8cd0dec602eab61015741269ad49df90cabf0521f9a32d134ceab4e21aa7f097258c55cc3baadef94874686d6480 + languageName: node + linkType: hard + "dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -16084,7 +16516,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -16120,6 +16552,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb + languageName: node + linkType: hard + "diff@npm:^5.0.0, diff@npm:^5.1.0": version: 5.2.2 resolution: "diff@npm:5.2.2" @@ -16339,6 +16778,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -17320,6 +17766,13 @@ __metadata: languageName: node linkType: hard +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 + languageName: node + linkType: hard + "expand-template@npm:^2.0.3": version: 2.0.3 resolution: "expand-template@npm:2.0.3" @@ -17341,6 +17794,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^29.0.0, expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -17581,7 +18047,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.2": +"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -17691,6 +18157,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -17921,7 +18396,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -17931,7 +18406,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -17961,7 +18436,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -18146,7 +18621,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -18968,7 +19443,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.2.0": +"import-local@npm:^3.0.2, import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -19268,6 +19743,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -19275,7 +19757,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -19542,6 +20024,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": "npm:^7.12.3" + "@babel/parser": "npm:^7.14.7" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^6.3.0" + checksum: 10/bbc4496c2f304d799f8ec22202ab38c010ac265c441947f075c0f7d46bd440b45c00e46017cf9053453d42182d768b1d6ed0e70a142c95ab00df9843aa5ab80e + languageName: node + linkType: hard + "istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" @@ -19566,6 +20061,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 10/5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 + languageName: node + linkType: hard + "istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" @@ -19624,6 +20130,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d + languageName: node + linkType: hard + "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -19652,6 +20169,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d + languageName: node + linkType: hard + "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" @@ -19677,6 +20222,32 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 + languageName: node + linkType: hard + "jest-config@npm:30.4.2": version: 30.4.2 resolution: "jest-config@npm:30.4.2" @@ -19719,6 +20290,44 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + ts-node: + optional: true + checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b + languageName: node + linkType: hard + "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -19731,6 +20340,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 + languageName: node + linkType: hard + "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -19740,6 +20361,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: "npm:^3.0.0" + checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d + languageName: node + linkType: hard + "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -19753,6 +20383,19 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^30.4.1": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -19784,6 +20427,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 + languageName: node + linkType: hard + +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10/88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 + languageName: node + linkType: hard + "jest-haste-map@npm:30.4.1": version: 30.4.1 resolution: "jest-haste-map@npm:30.4.1" @@ -19806,6 +20470,29 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" + "@types/node": "npm:*" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.3.2" + graceful-fs: "npm:^4.2.9" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10/8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 + languageName: node + linkType: hard + "jest-leak-detector@npm:30.4.1": version: 30.4.1 resolution: "jest-leak-detector@npm:30.4.1" @@ -19816,6 +20503,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 + languageName: node + linkType: hard + "jest-matcher-utils@npm:30.4.1": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -19828,6 +20525,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 + languageName: node + linkType: hard + "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -19846,6 +20555,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 + languageName: node + linkType: hard + "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -19857,7 +20583,18 @@ __metadata: languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.3": +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c + languageName: node + linkType: hard + +"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -19876,6 +20613,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 10/0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:30.4.2": version: 30.4.2 resolution: "jest-resolve-dependencies@npm:30.4.2" @@ -19886,6 +20630,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" + dependencies: + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 + languageName: node + linkType: hard + "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -19902,6 +20656,23 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 + languageName: node + linkType: hard + "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -19932,6 +20703,35 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e + languageName: node + linkType: hard + "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -19962,6 +20762,36 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 + languageName: node + linkType: hard + "jest-silent-reporter@npm:^0.6.0": version: 0.6.0 resolution: "jest-silent-reporter@npm:0.6.0" @@ -20001,6 +20831,34 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 + languageName: node + linkType: hard + "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -20057,6 +20915,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + leven: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 + languageName: node + linkType: hard + "jest-watcher@npm:30.4.1": version: 30.4.1 resolution: "jest-watcher@npm:30.4.1" @@ -20073,6 +20945,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 + languageName: node + linkType: hard + "jest-when@npm:^3.7.0": version: 3.7.0 resolution: "jest-when@npm:3.7.0" @@ -20106,7 +20994,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^29.4.3": +"jest-worker@npm:^29.4.3, jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: @@ -20118,6 +21006,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a + languageName: node + linkType: hard + "jest@npm:^30.4.2": version: 30.4.2 resolution: "jest@npm:30.4.2" @@ -20533,6 +21440,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -20609,6 +21526,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -21675,7 +22599,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -22758,7 +23682,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -22785,6 +23709,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -23031,6 +23964,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -23178,7 +24118,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": +"pirates@npm:^4.0.4, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 @@ -24118,6 +25058,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10/dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb + languageName: node + linkType: hard + "pretty-time@npm:^1.1.0": version: 1.1.0 resolution: "pretty-time@npm:1.1.0" @@ -24199,7 +25150,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -24318,6 +25269,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "pure-rand@npm:^7.0.0": version: 7.0.1 resolution: "pure-rand@npm:7.0.1" @@ -24489,7 +25447,7 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1": +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 @@ -24988,6 +25946,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -25046,6 +26011,13 @@ __metadata: languageName: node linkType: hard +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a + languageName: node + linkType: hard + "resolve@npm:1.22.8": version: 1.22.8 resolution: "resolve@npm:1.22.8" @@ -25059,7 +26031,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.22.11, resolve@npm:^1.22.4": +"resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -25086,7 +26058,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": +"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -25387,7 +26359,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -25396,7 +26368,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1": +"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1, semver@npm:^7.8.5": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -25488,6 +26460,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -25641,7 +26620,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -26018,7 +26997,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -26063,7 +27042,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.2": +"string-length@npm:^4.0.1, string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -26084,6 +27063,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -26143,6 +27133,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -26658,6 +27657,46 @@ __metadata: languageName: node linkType: hard +"ts-jest@npm:^29.2.5": + version: 29.4.12 + resolution: "ts-jest@npm:29.4.12" + dependencies: + bs-logger: "npm:^0.2.6" + fast-json-stable-stringify: "npm:^2.1.0" + handlebars: "npm:^4.7.9" + json5: "npm:^2.2.3" + lodash.memoize: "npm:^4.1.2" + make-error: "npm:^1.3.6" + semver: "npm:^7.8.5" + type-fest: "npm:^4.41.0" + yargs-parser: "npm:^21.1.1" + peerDependencies: + "@babel/core": ">=7.0.0-beta.0 <8" + "@jest/transform": ^29.0.0 || ^30.0.0 + "@jest/types": ^29.0.0 || ^30.0.0 + babel-jest: ^29.0.0 || ^30.0.0 + jest: ^29.0.0 || ^30.0.0 + jest-util: ^29.0.0 || ^30.0.0 + typescript: ">=4.3 <7" + peerDependenciesMeta: + "@babel/core": + optional: true + "@jest/transform": + optional: true + "@jest/types": + optional: true + babel-jest: + optional: true + esbuild: + optional: true + jest-util: + optional: true + bin: + ts-jest: cli.js + checksum: 10/640678eb12d0911d99b0a2ac52ebfbf0ab170ba24ab73697d71de0e00ee95f2f468299a877a5e27dadf08265d5588e8cc982a6945c16b8893657390de7baa662 + languageName: node + linkType: hard + "ts-jest@npm:^29.4.11": version: 29.4.11 resolution: "ts-jest@npm:29.4.11" @@ -27830,6 +28869,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -27924,6 +28970,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -27972,6 +29029,16 @@ __metadata: languageName: node linkType: hard +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 10/3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 + languageName: node + linkType: hard + "write-file-atomic@npm:^5.0.0, write-file-atomic@npm:^5.0.1": version: 5.0.1 resolution: "write-file-atomic@npm:5.0.1" @@ -28097,6 +29164,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -28134,6 +29208,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -28156,6 +29240,39 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + +"yargs@npm:^17.3.1": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From f69ee7ec55d9521207a11404b4e5b92c6962e32b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 31 Jul 2026 22:58:50 +0200 Subject: [PATCH 28/67] fix: lint issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/README.md | 14 +++++++------- packages/kyc-controller/src/KycController.test.ts | 12 ++++++++---- packages/kyc-controller/src/KycService.test.ts | 4 +--- .../src/ukyc/deriveClientMaterial.test.ts | 6 +++--- .../src/ukyc/deriveClientMaterial.ts | 2 +- packages/kyc-controller/src/ukyc/jwtChain.ts | 11 +++++------ .../kyc-controller/src/ukyc/localUserSecret.ts | 6 +----- .../src/ukyc/storageAccessToken.test.ts | 10 ++++++---- .../kyc-controller/src/ukyc/storageAccessToken.ts | 6 ++++-- packages/kyc-controller/src/ukyc/wrapUserKey.ts | 2 +- .../src/ukyc/wrappedRelayPayload.test.ts | 6 +++--- 11 files changed, 40 insertions(+), 39 deletions(-) diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 55a9ff27473..9076fab79c7 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -149,14 +149,14 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): Endpoints: -| Method | HTTP | Endpoint | Purpose | -| ------------------- | ------ | --------------------------------------- | -------------------------------------------------------- | -| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | -| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | -| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | -| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| Method | HTTP | Endpoint | Purpose | +| ------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | | `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | -| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | +| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | ### 2.3 `crypto.ts` diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index fea2fbca55e..e26ddac6e48 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1272,7 +1272,9 @@ describe('KycController', () => { const result = await controller.startSumSub(); expect(result).toMatchObject({ - error: expect.stringContaining('sessionServerPublicKey does not match'), + error: expect.stringContaining( + 'sessionServerPublicKey does not match', + ), }); expect(controller.state.sumsub.status).toBe('failed'); expect(launcher.launch).not.toHaveBeenCalled(); @@ -1375,8 +1377,7 @@ describe('KycController', () => { controller.reset(); // Only the initial createJourney (session setup) should // have run. - const callsBeforeRefresh = - handlers.createJourney.mock.calls.length; + const callsBeforeRefresh = handlers.createJourney.mock.calls.length; try { await onTokenExpiration(); } catch (error) { @@ -1847,7 +1848,10 @@ function withController( 'KycService:getWrappingKey', handlers.getWrappingKey, ); - rootMessenger.registerActionHandler('KycService:fetchJwks', handlers.fetchJwks); + rootMessenger.registerActionHandler( + 'KycService:fetchJwks', + handlers.fetchJwks, + ); rootMessenger.registerActionHandler( 'KycService:createUkycSession', handlers.createUkycSession, diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 0a0e19ccac8..eeec99c95ad 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -230,9 +230,7 @@ describe('KycService', () => { const response = { keys: [{ kty: 'OKP', crv: 'Ed25519', x: 'pub', kid: 'k1' }], }; - nock(MOCK_FRACTAL_URL) - .get('/.well-known/jwks.json') - .reply(200, response); + nock(MOCK_FRACTAL_URL).get('/.well-known/jwks.json').reply(200, response); const { service } = getService(); expect(await service.fetchJwks()).toStrictEqual(response); diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts index a5506311e0e..9b2741cda9e 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts @@ -10,9 +10,9 @@ import { encodeClientMaterial, } from './deriveClientMaterial'; -const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( - 42, -); +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); const OTHER_LOCAL_USER_SECRET = new Uint8Array( UKYC_LOCAL_USER_SECRET_SIZE_BYTES, ).fill(43); diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts index e796d1125e7..b0c34adb7e7 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -1,7 +1,7 @@ +import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { stringToBytes } from '@metamask/utils'; import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants'; import { toBase64Url } from './encoding'; diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts index 5b1ff269dc3..20cbbb56f6e 100644 --- a/packages/kyc-controller/src/ukyc/jwtChain.ts +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -1,5 +1,5 @@ -import { ed25519 } from '@noble/curves/ed25519'; import { bytesToString } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; import { base64UrlToBytes } from './encoding'; @@ -54,7 +54,9 @@ function decodeJsonSegment(segment: string, label: string): Type { try { return JSON.parse(bytesToString(base64UrlToBytes(segment))) as Type; } catch (error) { - throw new Error(`UKYC: failed to decode jwtChain ${label}: ${String(error)}`); + throw new Error( + `UKYC: failed to decode jwtChain ${label}: ${String(error)}`, + ); } } @@ -67,10 +69,7 @@ function decodeJsonSegment(segment: string, label: string): Type { * @param jwtChain - The compact-serialized EdDSA JWT from `getWrappingKey`. * @returns The verified JWT payload. */ -export function verifyJwtChain( - keys: Jwk[], - jwtChain: string, -): JwtChainPayload { +export function verifyJwtChain(keys: Jwk[], jwtChain: string): JwtChainPayload { const [headerSegment, payloadSegment, signatureSegment] = jwtChain.split('.'); if (!headerSegment || !payloadSegment || !signatureSegment) { throw new Error( diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts index e7a2db7c5b3..1469b2468f0 100644 --- a/packages/kyc-controller/src/ukyc/localUserSecret.ts +++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts @@ -34,11 +34,7 @@ export type UkycLocalUserSecretStore = { /** * Writes the base64 string `value` at `path`. */ - set: ( - path: string, - value: string, - entropySourceId?: string, - ) => Promise; + set: (path: string, value: string, entropySourceId?: string) => Promise; }; /** diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts index e656cb8c6bc..f88477ae266 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -13,9 +13,9 @@ import { signStorageAccessToken, } from './storageAccessToken'; -const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( - 42, -); +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); @@ -44,7 +44,9 @@ describe('UKYC canonicalizeJson', () => { }); it('drops undefined members', () => { - expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe('{"a":1,"c":2}'); + expect(canonicalizeJson({ a: 1, b: undefined, c: 2 })).toBe( + '{"a":1,"c":2}', + ); }); it('serializes primitives', () => { diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index 58d1b54c55c..25d9df6783e 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -1,5 +1,5 @@ -import { ed25519 } from '@noble/curves/ed25519'; import { stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; import { UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, @@ -111,7 +111,9 @@ export function canonicalizeJson(value: JsonValue): string { if (typeof value === 'number') { if (!Number.isInteger(value)) { - throw new Error('UKYC: cannot canonicalize a non-integer number for JCS.'); + throw new Error( + 'UKYC: cannot canonicalize a non-integer number for JCS.', + ); } return JSON.stringify(value); } diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts index 96eb830a63b..acf70d9752c 100644 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts @@ -1,9 +1,9 @@ +import { base64ToBytes } from '@metamask/utils'; import { gcm } from '@noble/ciphers/aes'; import { x25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { hexToBytes, randomBytes } from '@noble/hashes/utils'; -import { base64ToBytes } from '@metamask/utils'; import { toBase64Url } from './encoding'; diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts index 0e8e2b76b71..2a83bfaf3ee 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts @@ -4,9 +4,9 @@ import type { UkycStorageAccessToken } from './storageAccessToken'; import { signStorageAccessToken } from './storageAccessToken'; import { buildWrappedRelayPayload } from './wrappedRelayPayload'; -const LOCAL_USER_SECRET = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill( - 42, -); +const LOCAL_USER_SECRET = new Uint8Array( + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +).fill(42); const MATERIAL = deriveClientMaterial(LOCAL_USER_SECRET); const ISSUED_AT = new Date('2026-07-07T00:00:00.000Z'); const EXPIRES_AT = new Date('2026-07-07T04:00:00.000Z'); From 3c329297ecd18ce015d52dbd9dd56788a2ff6126 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 31 Jul 2026 23:14:40 +0200 Subject: [PATCH 29/67] fix: codeowner issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .github/CODEOWNERS | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 44caaad3744..5277d36f31c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -357,6 +357,7 @@ /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/snap-account-service/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/kyc-controller/tsconfig.* @MetaMask/universal-kyc @MetaMask/core-platform \ No newline at end of file From e35605e05a999e024c9e4e723e2e086e7cab24ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 31 Jul 2026 23:24:40 +0200 Subject: [PATCH 30/67] fix: deps constraints MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/package.json | 2 +- yarn.lock | 15 ++------------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 12ecdd97ee9..cf8e32329d8 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -58,7 +58,7 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/geolocation-controller": "^0.1.3", + "@metamask/geolocation-controller": "^1.0.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^28.3.0", "@metamask/superstruct": "^3.1.0", diff --git a/yarn.lock b/yarn.lock index 66b1f561858..af69977e170 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6806,7 +6806,7 @@ __metadata: languageName: node linkType: hard -"@metamask/controller-utils@npm:^12.0.0, @metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": +"@metamask/controller-utils@npm:^12.3.0, @metamask/controller-utils@workspace:packages/controller-utils": version: 0.0.0-use.local resolution: "@metamask/controller-utils@workspace:packages/controller-utils" dependencies: @@ -7637,17 +7637,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/geolocation-controller@npm:^0.1.3": - version: 0.1.3 - resolution: "@metamask/geolocation-controller@npm:0.1.3" - dependencies: - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.0.0" - "@metamask/messenger": "npm:^1.2.0" - checksum: 10/fbcde8af142027866dce845fb147b9ed55bda6edc475112f54d8f0cc197d54d277d67a547c035b3f4ab5f48b7ba1dd5b721c38c1025599cde65cb11a17d1f2da - languageName: node - linkType: hard - "@metamask/geolocation-controller@npm:^1.0.0, @metamask/geolocation-controller@workspace:packages/geolocation-controller": version: 0.0.0-use.local resolution: "@metamask/geolocation-controller@workspace:packages/geolocation-controller" @@ -7908,7 +7897,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/geolocation-controller": "npm:^0.1.3" + "@metamask/geolocation-controller": "npm:^1.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^28.3.0" "@metamask/superstruct": "npm:^3.1.0" From dda438f30ea0ee3171e3e542f4f77e05cec52940 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 31 Jul 2026 23:49:23 +0200 Subject: [PATCH 31/67] fix: eslint file-extension-in-import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../src/KycController-method-action-types.ts | 2 +- .../kyc-controller/src/KycController.test.ts | 8 ++--- packages/kyc-controller/src/KycController.ts | 14 ++++----- .../src/KycService-method-action-types.ts | 2 +- .../kyc-controller/src/KycService.test.ts | 6 ++-- packages/kyc-controller/src/KycService.ts | 10 +++---- .../kyc-controller/src/countryCodes.test.ts | 2 +- packages/kyc-controller/src/crypto.test.ts | 4 +-- packages/kyc-controller/src/index.test.ts | 2 +- packages/kyc-controller/src/index.ts | 26 ++++++++-------- packages/kyc-controller/src/selectors.test.ts | 4 +-- packages/kyc-controller/src/selectors.ts | 4 +-- .../src/ukyc/deriveClientMaterial.test.ts | 4 +-- .../src/ukyc/deriveClientMaterial.ts | 4 +-- .../kyc-controller/src/ukyc/encoding.test.ts | 2 +- packages/kyc-controller/src/ukyc/index.ts | 30 +++++++++---------- .../kyc-controller/src/ukyc/jwtChain.test.ts | 6 ++-- packages/kyc-controller/src/ukyc/jwtChain.ts | 2 +- .../src/ukyc/localUserSecret.test.ts | 6 ++-- .../src/ukyc/localUserSecret.ts | 2 +- .../src/ukyc/storageAccessToken.test.ts | 6 ++-- .../src/ukyc/storageAccessToken.ts | 6 ++-- .../src/ukyc/wrapEncryptionKey.test.ts | 20 ++++++------- .../src/ukyc/wrapEncryptionKey.ts | 8 ++--- .../src/ukyc/wrapUserKey.test.ts | 2 +- .../kyc-controller/src/ukyc/wrapUserKey.ts | 2 +- .../src/ukyc/wrappedRelayPayload.test.ts | 10 +++---- .../src/ukyc/wrappedRelayPayload.ts | 6 ++-- 28 files changed, 100 insertions(+), 100 deletions(-) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 941da3a5dca..548cb505a33 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { KycController } from './KycController'; +import type { KycController } from './KycController.js'; /** * Resolves persisted terms + geolocation, and auto-creates a session when diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index e26ddac6e48..2b3df2d25fd 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -10,10 +10,10 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; -import { KycController } from './KycController'; -import type { KycControllerMessenger } from './KycController'; -import type { KycSumSubLauncher } from './types'; -import { verifyJwtChain, wrapEncryptionKey } from './ukyc'; +import { KycController } from './KycController.js'; +import type { KycControllerMessenger } from './KycController.js'; +import type { KycSumSubLauncher } from './types.js'; +import { verifyJwtChain, wrapEncryptionKey } from './ukyc.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 2a5b6e2ccde..1aea26c1a97 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -12,10 +12,10 @@ import type { import type { Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; -import { decryptCredentials, generateKeyPair } from './crypto'; -import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto'; -import type { KycControllerMethodActions } from './KycController-method-action-types'; -import type { KycServiceMethodActions } from './KycService-method-action-types'; +import { decryptCredentials, generateKeyPair } from './crypto.js'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import type { KycControllerMethodActions } from './KycController-method-action-types.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { KycDisclaimer, KycPhase, @@ -23,7 +23,7 @@ import type { KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, -} from './types'; +} from './types.js'; import { deriveClientMaterial, getOrCreateLocalUserSecret, @@ -31,8 +31,8 @@ import { toBase64Url, verifyJwtChain, wrapEncryptionKey, -} from './ukyc'; -import type { UkycLocalUserSecretStore } from './ukyc'; +} from './ukyc.js'; +import type { UkycLocalUserSecretStore } from './ukyc.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index a1acc2b68c7..2d642d9ab27 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { KycService } from './KycService'; +import type { KycService } from './KycService.js'; /** * Resolves the customer's country from the geolocation source and converts it diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index eeec99c95ad..154252e264e 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -6,14 +6,14 @@ import type { } from '@metamask/messenger'; import nock, { cleanAll } from 'nock'; -import type { KycServiceMessenger } from './KycService'; -import { KycService } from './KycService'; +import type { KycServiceMessenger } from './KycService.js'; +import { KycService } from './KycService.js'; import { deriveClientMaterial, encodeStorageAccessTokenForHeader, signStorageAccessToken, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './ukyc'; +} from './ukyc.js'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 159c4c45ea1..91e90b3145b 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -17,11 +17,11 @@ import { type, } from '@metamask/superstruct'; -import { alpha2ToAlpha3 } from './countryCodes'; -import type { KycServiceMethodActions } from './KycService-method-action-types'; -import type { KycDisclaimer, KycSessionStatus } from './types'; -import { encodeStorageAccessTokenForHeader, UKYC_JWKS_PATH } from './ukyc'; -import type { UkycStorageAccessToken } from './ukyc'; +import { alpha2ToAlpha3 } from './countryCodes.js'; +import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import type { KycDisclaimer, KycSessionStatus } from './types.js'; +import { encodeStorageAccessTokenForHeader, UKYC_JWKS_PATH } from './ukyc.js'; +import type { UkycStorageAccessToken } from './ukyc.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts index 55446663199..9188868966d 100644 --- a/packages/kyc-controller/src/countryCodes.test.ts +++ b/packages/kyc-controller/src/countryCodes.test.ts @@ -1,4 +1,4 @@ -import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes'; +import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes.js'; describe('countryCodes', () => { it('exposes the alpha-2 to alpha-3 map', () => { diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts index b26d985c9ac..45bfc7b190b 100644 --- a/packages/kyc-controller/src/crypto.test.ts +++ b/packages/kyc-controller/src/crypto.test.ts @@ -5,8 +5,8 @@ import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; import { base64 } from '@scure/base'; -import type { EncryptedCredentialsEnvelope } from './crypto'; -import { decryptCredentials, generateKeyPair } from './crypto'; +import type { EncryptedCredentialsEnvelope } from './crypto.js'; +import { decryptCredentials, generateKeyPair } from './crypto.js'; /** * Builds an encrypted-credentials envelope that `decryptCredentials` can diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index b95071359e1..f986f8847a4 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -1,4 +1,4 @@ -import * as packageExports from '.'; +import * as packageExports from './index.js'; describe('@metamask/kyc-controller', () => { it('exports the controller, service, selectors, and helpers', () => { diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 8e5897d54fd..1a5de4d7c86 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -2,7 +2,7 @@ export { KycController, getDefaultKycControllerState, controllerName, -} from './KycController'; +} from './KycController.js'; export type { KycControllerActions, KycControllerEvents, @@ -11,7 +11,7 @@ export type { KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, -} from './KycController'; +} from './KycController.js'; export type { KycControllerAcceptTermsAndStartSessionAction, KycControllerBuildAuthFrameUrlAction, @@ -26,9 +26,9 @@ export type { KycControllerLoadDisclaimersAction, KycControllerResetAction, KycControllerStartSumSubAction, -} from './KycController-method-action-types'; +} from './KycController-method-action-types.js'; -export { KycService, serviceName } from './KycService'; +export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse, CheckKycRequiredParams, @@ -45,7 +45,7 @@ export type { UkycSessionResponse, WrappedEncryptionKey, WrappingKeyResponse, -} from './KycService'; +} from './KycService.js'; export type { KycServiceCheckKycRequiredAction, KycServiceCreateJourneyAction, @@ -56,22 +56,22 @@ export type { KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, -} from './KycService-method-action-types'; +} from './KycService-method-action-types.js'; export { selectIsKycRequiredForProduct, selectKycPhase, selectKycSumSub, -} from './selectors'; +} from './selectors.js'; -export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes'; -export { decryptCredentials, generateKeyPair } from './crypto'; +export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes.js'; +export { decryptCredentials, generateKeyPair } from './crypto.js'; export type { DecryptedCredentials, DecryptResult, EncryptedCredentialsEnvelope, X25519KeyPair, -} from './crypto'; +} from './crypto.js'; export type { KycDisclaimer, @@ -82,7 +82,7 @@ export type { KycSumSubLauncher, KycSumSubStatus, KycVendor, -} from './types'; +} from './types.js'; export { base64UrlToBytes, @@ -106,7 +106,7 @@ export { verifyJwtChain, wrapEncryptionKey, wrapUserKey, -} from './ukyc'; +} from './ukyc.js'; export type { EncodedUkycClientMaterial, Jwk, @@ -120,4 +120,4 @@ export type { UkycTokenPresenter, UkycWrappedRelayPayload, WrappedEncryptionKeyParts, -} from './ukyc'; +} from './ukyc.js'; diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts index 725ec0e592d..5eee934acd0 100644 --- a/packages/kyc-controller/src/selectors.test.ts +++ b/packages/kyc-controller/src/selectors.test.ts @@ -1,9 +1,9 @@ -import { getDefaultKycControllerState } from './KycController'; +import { getDefaultKycControllerState } from './KycController.js'; import { selectIsKycRequiredForProduct, selectKycPhase, selectKycSumSub, -} from './selectors'; +} from './selectors.js'; describe('selectors', () => { it('selectKycPhase returns the current phase', () => { diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts index 658169a15fc..6247e01796f 100644 --- a/packages/kyc-controller/src/selectors.ts +++ b/packages/kyc-controller/src/selectors.ts @@ -1,7 +1,7 @@ import { createSelector } from 'reselect'; -import type { KycControllerState } from './KycController'; -import type { KycProduct } from './types'; +import type { KycControllerState } from './KycController.js'; +import type { KycProduct } from './types.js'; const selectKycRequiredByProduct = ( state: KycControllerState, diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts index 9b2741cda9e..17ae0da5bdb 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.test.ts @@ -4,11 +4,11 @@ import { ed25519 } from '@noble/curves/ed25519'; import { UKYC_DERIVED_KEY_SIZES, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './constants'; +} from './constants.js'; import { deriveClientMaterial, encodeClientMaterial, -} from './deriveClientMaterial'; +} from './deriveClientMaterial.js'; const LOCAL_USER_SECRET = new Uint8Array( UKYC_LOCAL_USER_SECRET_SIZE_BYTES, diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts index b0c34adb7e7..d0e621d91ba 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -3,8 +3,8 @@ import { ed25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants'; -import { toBase64Url } from './encoding'; +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; +import { toBase64Url } from './encoding.js'; /** * Derives UKYC client material from the root `local_user_secret` using diff --git a/packages/kyc-controller/src/ukyc/encoding.test.ts b/packages/kyc-controller/src/ukyc/encoding.test.ts index 63949973cd3..43bc0199dd1 100644 --- a/packages/kyc-controller/src/ukyc/encoding.test.ts +++ b/packages/kyc-controller/src/ukyc/encoding.test.ts @@ -1,6 +1,6 @@ import { areUint8ArraysEqual } from '@metamask/utils'; -import { base64UrlToBytes, toBase64Url } from './encoding'; +import { base64UrlToBytes, toBase64Url } from './encoding.js'; describe('UKYC encoding', () => { describe('toBase64Url', () => { diff --git a/packages/kyc-controller/src/ukyc/index.ts b/packages/kyc-controller/src/ukyc/index.ts index 825488c1955..3f09304c29f 100644 --- a/packages/kyc-controller/src/ukyc/index.ts +++ b/packages/kyc-controller/src/ukyc/index.ts @@ -6,38 +6,38 @@ export { UKYC_LOCAL_USER_SECRET_SIZE_BYTES, UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_STORAGE_ACCESS_TOKEN_VERSION, -} from './constants'; -export { base64UrlToBytes, toBase64Url } from './encoding'; +} from './constants.js'; +export { base64UrlToBytes, toBase64Url } from './encoding.js'; export { deriveClientMaterial, encodeClientMaterial, -} from './deriveClientMaterial'; +} from './deriveClientMaterial.js'; export type { EncodedUkycClientMaterial, UkycClientMaterial, -} from './deriveClientMaterial'; -export { verifyJwtChain } from './jwtChain'; -export type { Jwk, JwtChainPayload } from './jwtChain'; +} from './deriveClientMaterial.js'; +export { verifyJwtChain } from './jwtChain.js'; +export type { Jwk, JwtChainPayload } from './jwtChain.js'; export { getOrCreateLocalUserSecret, hasLocalUserSecret, loadLocalUserSecret, -} from './localUserSecret'; -export type { UkycLocalUserSecretStore } from './localUserSecret'; +} from './localUserSecret.js'; +export type { UkycLocalUserSecretStore } from './localUserSecret.js'; export { canonicalizeJson, encodeStorageAccessTokenForHeader, signStorageAccessToken, -} from './storageAccessToken'; +} from './storageAccessToken.js'; export type { SignStorageAccessTokenParams, UkycStorageAccessToken, UkycStorageAccessTokenPayload, UkycStorageOperation, UkycTokenPresenter, -} from './storageAccessToken'; -export { wrapEncryptionKey } from './wrapEncryptionKey'; -export type { WrappedEncryptionKeyParts } from './wrapEncryptionKey'; -export { wrapUserKey } from './wrapUserKey'; -export { buildWrappedRelayPayload } from './wrappedRelayPayload'; -export type { UkycWrappedRelayPayload } from './wrappedRelayPayload'; +} from './storageAccessToken.js'; +export { wrapEncryptionKey } from './wrapEncryptionKey.js'; +export type { WrappedEncryptionKeyParts } from './wrapEncryptionKey.js'; +export { wrapUserKey } from './wrapUserKey.js'; +export { buildWrappedRelayPayload } from './wrappedRelayPayload.js'; +export type { UkycWrappedRelayPayload } from './wrappedRelayPayload.js'; diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts index fd6244e7b65..e8d89c151b5 100644 --- a/packages/kyc-controller/src/ukyc/jwtChain.test.ts +++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts @@ -1,9 +1,9 @@ import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; -import { toBase64Url } from './encoding'; -import type { Jwk } from './jwtChain'; -import { verifyJwtChain } from './jwtChain'; +import { toBase64Url } from './encoding.js'; +import type { Jwk } from './jwtChain.js'; +import { verifyJwtChain } from './jwtChain.js'; const KID = 'key-1'; const PAYLOAD = { sessionServerPublicKeyX: 'spk-x', nonce: 'nonce-1' }; diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts index 20cbbb56f6e..d3ae4def629 100644 --- a/packages/kyc-controller/src/ukyc/jwtChain.ts +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -1,7 +1,7 @@ import { bytesToString } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; -import { base64UrlToBytes } from './encoding'; +import { base64UrlToBytes } from './encoding.js'; /** * Verifies the `jwtChain` returned by the Fractal encryption service against diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts index 51f5e50f7f8..b2e7869813d 100644 --- a/packages/kyc-controller/src/ukyc/localUserSecret.test.ts +++ b/packages/kyc-controller/src/ukyc/localUserSecret.test.ts @@ -3,13 +3,13 @@ import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; import { UKYC_LOCAL_USER_SECRET_PATH, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './constants'; -import type { UkycLocalUserSecretStore } from './localUserSecret'; +} from './constants.js'; +import type { UkycLocalUserSecretStore } from './localUserSecret.js'; import { getOrCreateLocalUserSecret, hasLocalUserSecret, loadLocalUserSecret, -} from './localUserSecret'; +} from './localUserSecret.js'; const SECRET_BYTES = new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(7); const SECRET_BASE64 = bytesToBase64(SECRET_BYTES); diff --git a/packages/kyc-controller/src/ukyc/localUserSecret.ts b/packages/kyc-controller/src/ukyc/localUserSecret.ts index 1469b2468f0..46583684e96 100644 --- a/packages/kyc-controller/src/ukyc/localUserSecret.ts +++ b/packages/kyc-controller/src/ukyc/localUserSecret.ts @@ -4,7 +4,7 @@ import { randomBytes } from '@noble/hashes/utils'; import { UKYC_LOCAL_USER_SECRET_PATH, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './constants'; +} from './constants.js'; /** * Orchestrates creation and loading of the UKYC `local_user_secret`. diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts index f88477ae266..905d549490d 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -5,13 +5,13 @@ import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES, UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_STORAGE_ACCESS_TOKEN_VERSION, -} from './constants'; -import { deriveClientMaterial } from './deriveClientMaterial'; +} from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; import { canonicalizeJson, encodeStorageAccessTokenForHeader, signStorageAccessToken, -} from './storageAccessToken'; +} from './storageAccessToken.js'; const LOCAL_USER_SECRET = new Uint8Array( UKYC_LOCAL_USER_SECRET_SIZE_BYTES, diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index 25d9df6783e..2e2d2490acb 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -4,9 +4,9 @@ import { ed25519 } from '@noble/curves/ed25519'; import { UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_STORAGE_ACCESS_TOKEN_VERSION, -} from './constants'; -import type { UkycClientMaterial } from './deriveClientMaterial'; -import { toBase64Url } from './encoding'; +} from './constants.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; +import { toBase64Url } from './encoding.js'; /** * Mints `storage_access_token` capabilities — the client-signed, scoped, diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts index 96a2b507baf..97df6e24b1c 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -1,8 +1,8 @@ import { areUint8ArraysEqual } from '@metamask/utils'; -import nacl from 'tweetnacl'; +import { box } from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding'; -import { wrapEncryptionKey } from './wrapEncryptionKey'; +import { base64UrlToBytes, toBase64Url } from './encoding.js'; +import { wrapEncryptionKey } from './wrapEncryptionKey.js'; const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); @@ -22,7 +22,7 @@ function unwrap( encryptedKey: string, nonce: string, ): Uint8Array { - const recovered = nacl.box.open( + const recovered = box.open( base64UrlToBytes(encryptedKey), base64UrlToBytes(nonce), clientPublicKey, @@ -36,8 +36,8 @@ function unwrap( describe('UKYC wrapEncryptionKey', () => { it('wraps a key the session server can recover', () => { - const serverKeyPair = nacl.box.keyPair(); - const clientKeyPair = nacl.box.keyPair(); + const serverKeyPair = box.keyPair(); + const clientKeyPair = box.keyPair(); const { encryptedKey, nonce } = wrapEncryptionKey( clientKeyPair.secretKey, @@ -55,8 +55,8 @@ describe('UKYC wrapEncryptionKey', () => { }); it('emits base64url fields', () => { - const serverPublicKey = nacl.box.keyPair().publicKey; - const clientPrivateKey = nacl.box.keyPair().secretKey; + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; const { encryptedKey, nonce } = wrapEncryptionKey( clientPrivateKey, @@ -69,8 +69,8 @@ describe('UKYC wrapEncryptionKey', () => { }); it('uses a fresh nonce per call', () => { - const serverPublicKey = nacl.box.keyPair().publicKey; - const clientPrivateKey = nacl.box.keyPair().secretKey; + const serverPublicKey = box.keyPair().publicKey; + const clientPrivateKey = box.keyPair().secretKey; const serverPublicKeyB64 = toBase64Url(serverPublicKey); const first = wrapEncryptionKey( diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts index 4a84f0256f2..aef922cbe63 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -1,6 +1,6 @@ -import nacl from 'tweetnacl'; +import { randomBytes, box } from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding'; +import { base64UrlToBytes, toBase64Url } from './encoding.js'; /** * Wraps the `data_encryption_key` for the UKYC session server using NaCl's @@ -42,8 +42,8 @@ export function wrapEncryptionKey( keyToWrap: Uint8Array, ): WrappedEncryptionKeyParts { const serverPublicKey = base64UrlToBytes(sessionServerPublicKey); - const nonce = nacl.randomBytes(nacl.box.nonceLength); - const encryptedKey = nacl.box( + const nonce = randomBytes(box.nonceLength); + const encryptedKey = box( keyToWrap, nonce, serverPublicKey, diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts index 4a1ef57114c..3842792c254 100644 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.test.ts @@ -5,7 +5,7 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex } from '@noble/hashes/utils'; -import { wrapUserKey } from './wrapUserKey'; +import { wrapUserKey } from './wrapUserKey.js'; const X25519_KEY_SIZE_BYTES = 32; const IV_SIZE_BYTES = 12; diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts index acf70d9752c..cd139d51676 100644 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts @@ -5,7 +5,7 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { hexToBytes, randomBytes } from '@noble/hashes/utils'; -import { toBase64Url } from './encoding'; +import { toBase64Url } from './encoding.js'; /** * Wraps (encrypts) a symmetric key so that only the holder of a given X25519 diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts index 2a83bfaf3ee..bfc422dd7fa 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.test.ts @@ -1,8 +1,8 @@ -import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants'; -import { deriveClientMaterial } from './deriveClientMaterial'; -import type { UkycStorageAccessToken } from './storageAccessToken'; -import { signStorageAccessToken } from './storageAccessToken'; -import { buildWrappedRelayPayload } from './wrappedRelayPayload'; +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './constants.js'; +import { deriveClientMaterial } from './deriveClientMaterial.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; +import { signStorageAccessToken } from './storageAccessToken.js'; +import { buildWrappedRelayPayload } from './wrappedRelayPayload.js'; const LOCAL_USER_SECRET = new Uint8Array( UKYC_LOCAL_USER_SECRET_SIZE_BYTES, diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts index b67e4a737ce..d5f6e136e2d 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -1,6 +1,6 @@ -import type { UkycClientMaterial } from './deriveClientMaterial'; -import { toBase64Url } from './encoding'; -import type { UkycStorageAccessToken } from './storageAccessToken'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; +import { toBase64Url } from './encoding.js'; +import type { UkycStorageAccessToken } from './storageAccessToken.js'; /** * Builds the `wrapped_relay_payload` — the only client-derived material that From 166c8b1da232d7212725b2a88c5e0e0cab1cb3dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Sat, 1 Aug 2026 00:02:45 +0200 Subject: [PATCH 32/67] fix: import lint issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/KycController.test.ts | 2 +- packages/kyc-controller/src/KycController.ts | 4 ++-- packages/kyc-controller/src/KycService.test.ts | 2 +- packages/kyc-controller/src/KycService.ts | 7 +++++-- packages/kyc-controller/src/index.ts | 4 ++-- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 2b3df2d25fd..1b223cabf93 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -13,7 +13,7 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; import { KycController } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; import type { KycSumSubLauncher } from './types.js'; -import { verifyJwtChain, wrapEncryptionKey } from './ukyc.js'; +import { verifyJwtChain, wrapEncryptionKey } from './ukyc/index.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 1aea26c1a97..4d3b64d67f3 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -31,8 +31,8 @@ import { toBase64Url, verifyJwtChain, wrapEncryptionKey, -} from './ukyc.js'; -import type { UkycLocalUserSecretStore } from './ukyc.js'; +} from './ukyc/index.js'; +import type { UkycLocalUserSecretStore } from './ukyc/index.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 154252e264e..645f34ee26f 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -13,7 +13,7 @@ import { encodeStorageAccessTokenForHeader, signStorageAccessToken, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './ukyc.js'; +} from './ukyc/index.js'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 91e90b3145b..3e0dee5af57 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -20,8 +20,11 @@ import { import { alpha2ToAlpha3 } from './countryCodes.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { KycDisclaimer, KycSessionStatus } from './types.js'; -import { encodeStorageAccessTokenForHeader, UKYC_JWKS_PATH } from './ukyc.js'; -import type { UkycStorageAccessToken } from './ukyc.js'; +import { + encodeStorageAccessTokenForHeader, + UKYC_JWKS_PATH, +} from './ukyc/index.js'; +import type { UkycStorageAccessToken } from './ukyc/index.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 1a5de4d7c86..1d1346c3c5b 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -106,7 +106,7 @@ export { verifyJwtChain, wrapEncryptionKey, wrapUserKey, -} from './ukyc.js'; +} from './ukyc/index.js'; export type { EncodedUkycClientMaterial, Jwk, @@ -120,4 +120,4 @@ export type { UkycTokenPresenter, UkycWrappedRelayPayload, WrappedEncryptionKeyParts, -} from './ukyc.js'; +} from './ukyc/index.js'; From f4b81bc7fddce8b803fd554fee5501200acf130d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Sat, 1 Aug 2026 00:11:52 +0200 Subject: [PATCH 33/67] fix: deps version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/package.json | 6 +- yarn.lock | 945 ++------------------------- 2 files changed, 42 insertions(+), 909 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index cf8e32329d8..946dd011545 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -73,12 +73,12 @@ "devDependencies": { "@metamask/auto-changelog": "^6.1.0", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^29.5.14", + "@types/jest": "^30.0.0", "chokidar-cli": "^3.0.0", "deepmerge": "^4.2.2", - "jest": "^29.7.0", + "jest": "^30.4.2", "nock": "^13.3.1", - "ts-jest": "^29.2.5", + "ts-jest": "^29.4.11", "tsx": "^4.20.5", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", diff --git a/yarn.lock b/yarn.lock index af69977e170..cb61ca8827c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -476,7 +476,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -494,7 +494,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -517,7 +517,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" dependencies: @@ -731,7 +731,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/parser@npm:7.29.7" dependencies: @@ -921,7 +921,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -1020,7 +1020,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1867,7 +1867,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": +"@babel/template@npm:^7.29.7": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -1893,7 +1893,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -4949,20 +4949,6 @@ __metadata: languageName: node linkType: hard -"@jest/console@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/console@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - slash: "npm:^3.0.0" - checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e - languageName: node - linkType: hard - "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -5004,47 +4990,6 @@ __metadata: languageName: node linkType: hard -"@jest/core@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/core@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/reporters": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-changed-files: "npm:^29.7.0" - jest-config: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-resolve-dependencies: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-ansi: "npm:^6.0.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e - languageName: node - linkType: hard - "@jest/diff-sequences@npm:30.4.0": version: 30.4.0 resolution: "@jest/diff-sequences@npm:30.4.0" @@ -5085,18 +5030,6 @@ __metadata: languageName: node linkType: hard -"@jest/environment@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/environment@npm:29.7.0" - dependencies: - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 - languageName: node - linkType: hard - "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -5106,15 +5039,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect-utils@npm:29.7.0" - dependencies: - jest-get-type: "npm:^29.6.3" - checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee - languageName: node - linkType: hard - "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -5125,16 +5049,6 @@ __metadata: languageName: node linkType: hard -"@jest/expect@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/expect@npm:29.7.0" - dependencies: - expect: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 - languageName: node - linkType: hard - "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -5149,20 +5063,6 @@ __metadata: languageName: node linkType: hard -"@jest/fake-timers@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/fake-timers@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@sinonjs/fake-timers": "npm:^10.0.2" - "@types/node": "npm:*" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 - languageName: node - linkType: hard - "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -5182,18 +5082,6 @@ __metadata: languageName: node linkType: hard -"@jest/globals@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/globals@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - jest-mock: "npm:^29.7.0" - checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 - languageName: node - linkType: hard - "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -5240,43 +5128,6 @@ __metadata: languageName: node linkType: hard -"@jest/reporters@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/reporters@npm:29.7.0" - dependencies: - "@bcoe/v8-coverage": "npm:^0.2.3" - "@jest/console": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@jridgewell/trace-mapping": "npm:^0.3.18" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - collect-v8-coverage: "npm:^1.0.0" - exit: "npm:^0.1.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - istanbul-lib-coverage: "npm:^3.0.0" - istanbul-lib-instrument: "npm:^6.0.0" - istanbul-lib-report: "npm:^3.0.0" - istanbul-lib-source-maps: "npm:^4.0.0" - istanbul-reports: "npm:^3.1.3" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - slash: "npm:^3.0.0" - string-length: "npm:^4.0.1" - strip-ansi: "npm:^6.0.0" - v8-to-istanbul: "npm:^9.0.1" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - checksum: 10/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc - languageName: node - linkType: hard - "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -5318,17 +5169,6 @@ __metadata: languageName: node linkType: hard -"@jest/source-map@npm:^29.6.3": - version: 29.6.3 - resolution: "@jest/source-map@npm:29.6.3" - dependencies: - "@jridgewell/trace-mapping": "npm:^0.3.18" - callsites: "npm:^3.0.0" - graceful-fs: "npm:^4.2.9" - checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb - languageName: node - linkType: hard - "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -5341,18 +5181,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-result@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-result@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/istanbul-lib-coverage": "npm:^2.0.0" - collect-v8-coverage: "npm:^1.0.0" - checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 - languageName: node - linkType: hard - "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -5365,18 +5193,6 @@ __metadata: languageName: node linkType: hard -"@jest/test-sequencer@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/test-sequencer@npm:29.7.0" - dependencies: - "@jest/test-result": "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - slash: "npm:^3.0.0" - checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 - languageName: node - linkType: hard - "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -5399,29 +5215,6 @@ __metadata: languageName: node linkType: hard -"@jest/transform@npm:^29.7.0": - version: 29.7.0 - resolution: "@jest/transform@npm:29.7.0" - dependencies: - "@babel/core": "npm:^7.11.6" - "@jest/types": "npm:^29.6.3" - "@jridgewell/trace-mapping": "npm:^0.3.18" - babel-plugin-istanbul: "npm:^6.1.1" - chalk: "npm:^4.0.0" - convert-source-map: "npm:^2.0.0" - fast-json-stable-stringify: "npm:^2.1.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - pirates: "npm:^4.0.4" - slash: "npm:^3.0.0" - write-file-atomic: "npm:^4.0.2" - checksum: 10/30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 - languageName: node - linkType: hard - "@jest/types@npm:30.4.1": version: 30.4.1 resolution: "@jest/types@npm:30.4.1" @@ -7907,13 +7700,13 @@ __metadata: "@noble/hashes": "npm:^1.8.0" "@scure/base": "npm:^1.2.6" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^29.5.14" + "@types/jest": "npm:^30.0.0" chokidar-cli: "npm:^3.0.0" deepmerge: "npm:^4.2.2" - jest: "npm:^29.7.0" + jest: "npm:^30.4.2" nock: "npm:^13.3.1" reselect: "npm:^5.1.1" - ts-jest: "npm:^29.2.5" + ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" tweetnacl: "npm:^1.0.3" typedoc: "npm:^0.25.13" @@ -11397,7 +11190,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -11406,15 +11199,6 @@ __metadata: languageName: node linkType: hard -"@sinonjs/fake-timers@npm:^10.0.2": - version: 10.3.0 - resolution: "@sinonjs/fake-timers@npm:10.3.0" - dependencies: - "@sinonjs/commons": "npm:^3.0.0" - checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e - languageName: node - linkType: hard - "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -11896,7 +11680,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -11928,7 +11712,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": +"@types/babel__traverse@npm:*": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -12117,15 +11901,6 @@ __metadata: languageName: node linkType: hard -"@types/graceful-fs@npm:^4.1.3": - version: 4.1.9 - resolution: "@types/graceful-fs@npm:4.1.9" - dependencies: - "@types/node": "npm:*" - checksum: 10/79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 - languageName: node - linkType: hard - "@types/gtag.js@npm:^0.0.20": version: 0.0.20 resolution: "@types/gtag.js@npm:0.0.20" @@ -12223,16 +11998,6 @@ __metadata: languageName: node linkType: hard -"@types/jest@npm:^29.5.14": - version: 29.5.14 - resolution: "@types/jest@npm:29.5.14" - dependencies: - expect: "npm:^29.0.0" - pretty-format: "npm:^29.0.0" - checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b - languageName: node - linkType: hard - "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -12549,7 +12314,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -14024,7 +13789,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -14045,7 +13810,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -14245,23 +14010,6 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:^29.7.0": - version: 29.7.0 - resolution: "babel-jest@npm:29.7.0" - dependencies: - "@jest/transform": "npm:^29.7.0" - "@types/babel__core": "npm:^7.1.14" - babel-plugin-istanbul: "npm:^6.1.1" - babel-preset-jest: "npm:^29.6.3" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.8.0 - checksum: 10/8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 - languageName: node - linkType: hard - "babel-loader@npm:^9.2.1": version: 9.2.1 resolution: "babel-loader@npm:9.2.1" @@ -14284,19 +14032,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^6.1.1": - version: 6.1.1 - resolution: "babel-plugin-istanbul@npm:6.1.1" - dependencies: - "@babel/helper-plugin-utils": "npm:^7.0.0" - "@istanbuljs/load-nyc-config": "npm:^1.0.0" - "@istanbuljs/schema": "npm:^0.1.2" - istanbul-lib-instrument: "npm:^5.0.4" - test-exclude: "npm:^6.0.0" - checksum: 10/ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d - languageName: node - linkType: hard - "babel-plugin-istanbul@npm:^7.0.1": version: 7.0.1 resolution: "babel-plugin-istanbul@npm:7.0.1" @@ -14319,18 +14054,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:^29.6.3": - version: 29.6.3 - resolution: "babel-plugin-jest-hoist@npm:29.6.3" - dependencies: - "@babel/template": "npm:^7.3.3" - "@babel/types": "npm:^7.3.3" - "@types/babel__core": "npm:^7.1.14" - "@types/babel__traverse": "npm:^7.0.6" - checksum: 10/9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 - languageName: node - linkType: hard - "babel-plugin-polyfill-corejs2@npm:^0.4.14, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" @@ -14379,7 +14102,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.2.0": +"babel-preset-current-node-syntax@npm:^1.2.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -14416,18 +14139,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:^29.6.3": - version: 29.6.3 - resolution: "babel-preset-jest@npm:29.6.3" - dependencies: - babel-plugin-jest-hoist: "npm:^29.6.3" - babel-preset-current-node-syntax: "npm:^1.0.0" - peerDependencies: - "@babel/core": ^7.0.0 - checksum: 10/aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb - languageName: node - linkType: hard - "babel-runtime@npm:^6.26.0": version: 6.26.0 resolution: "babel-runtime@npm:6.26.0" @@ -15282,13 +14993,6 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.0.0": - version: 1.4.3 - resolution: "cjs-module-lexer@npm:1.4.3" - checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 - languageName: node - linkType: hard - "cjs-module-lexer@npm:^1.3.1": version: 1.4.0 resolution: "cjs-module-lexer@npm:1.4.0" @@ -15499,7 +15203,7 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.0, collect-v8-coverage@npm:^1.0.2": +"collect-v8-coverage@npm:^1.0.2": version: 1.0.3 resolution: "collect-v8-coverage@npm:1.0.3" checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 @@ -15930,23 +15634,6 @@ __metadata: languageName: node linkType: hard -"create-jest@npm:^29.7.0": - version: 29.7.0 - resolution: "create-jest@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - exit: "npm:^0.1.2" - graceful-fs: "npm:^4.2.9" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - prompts: "npm:^2.0.1" - bin: - create-jest: bin/create-jest.js - checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 - languageName: node - linkType: hard - "cron-parser@npm:^4.5.0": version: 4.9.0 resolution: "cron-parser@npm:4.9.0" @@ -16337,18 +16024,6 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.0.0": - version: 1.7.2 - resolution: "dedent@npm:1.7.2" - peerDependencies: - babel-plugin-macros: ^3.1.0 - peerDependenciesMeta: - babel-plugin-macros: - optional: true - checksum: 10/30b9062290dca72b0f5a6cd3667633448cef8cd0dec602eab61015741269ad49df90cabf0521f9a32d134ceab4e21aa7f097258c55cc3baadef94874686d6480 - languageName: node - linkType: hard - "dedent@npm:^1.6.0": version: 1.7.1 resolution: "dedent@npm:1.7.1" @@ -16505,7 +16180,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -16541,13 +16216,6 @@ __metadata: languageName: node linkType: hard -"diff-sequences@npm:^29.6.3": - version: 29.6.3 - resolution: "diff-sequences@npm:29.6.3" - checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb - languageName: node - linkType: hard - "diff@npm:^5.0.0, diff@npm:^5.1.0": version: 5.2.2 resolution: "diff@npm:5.2.2" @@ -17755,13 +17423,6 @@ __metadata: languageName: node linkType: hard -"exit@npm:^0.1.2": - version: 0.1.2 - resolution: "exit@npm:0.1.2" - checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 - languageName: node - linkType: hard - "expand-template@npm:^2.0.3": version: 2.0.3 resolution: "expand-template@npm:2.0.3" @@ -17783,19 +17444,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^29.0.0, expect@npm:^29.7.0": - version: 29.7.0 - resolution: "expect@npm:29.7.0" - dependencies: - "@jest/expect-utils": "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -18036,7 +17684,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": +"fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -18385,7 +18033,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -18395,7 +18043,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -18610,7 +18258,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -19432,7 +19080,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.0.2, import-local@npm:^3.2.0": +"import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -19746,7 +19394,7 @@ __metadata: languageName: node linkType: hard -"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -20013,19 +19661,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-instrument@npm:^5.0.4": - version: 5.2.1 - resolution: "istanbul-lib-instrument@npm:5.2.1" - dependencies: - "@babel/core": "npm:^7.12.3" - "@babel/parser": "npm:^7.14.7" - "@istanbuljs/schema": "npm:^0.1.2" - istanbul-lib-coverage: "npm:^3.2.0" - semver: "npm:^6.3.0" - checksum: 10/bbc4496c2f304d799f8ec22202ab38c010ac265c441947f075c0f7d46bd440b45c00e46017cf9053453d42182d768b1d6ed0e70a142c95ab00df9843aa5ab80e - languageName: node - linkType: hard - "istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" @@ -20050,17 +19685,6 @@ __metadata: languageName: node linkType: hard -"istanbul-lib-source-maps@npm:^4.0.0": - version: 4.0.1 - resolution: "istanbul-lib-source-maps@npm:4.0.1" - dependencies: - debug: "npm:^4.1.1" - istanbul-lib-coverage: "npm:^3.0.0" - source-map: "npm:^0.6.1" - checksum: 10/5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 - languageName: node - linkType: hard - "istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" @@ -20119,17 +19743,6 @@ __metadata: languageName: node linkType: hard -"jest-changed-files@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-changed-files@npm:29.7.0" - dependencies: - execa: "npm:^5.0.0" - jest-util: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d - languageName: node - linkType: hard - "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -20158,34 +19771,6 @@ __metadata: languageName: node linkType: hard -"jest-circus@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-circus@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/expect": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - co: "npm:^4.6.0" - dedent: "npm:^1.0.0" - is-generator-fn: "npm:^2.0.0" - jest-each: "npm:^29.7.0" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - pretty-format: "npm:^29.7.0" - pure-rand: "npm:^6.0.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d - languageName: node - linkType: hard - "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" @@ -20211,32 +19796,6 @@ __metadata: languageName: node linkType: hard -"jest-cli@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-cli@npm:29.7.0" - dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - create-jest: "npm:^29.7.0" - exit: "npm:^0.1.2" - import-local: "npm:^3.0.2" - jest-config: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - yargs: "npm:^17.3.1" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 - languageName: node - linkType: hard - "jest-config@npm:30.4.2": version: 30.4.2 resolution: "jest-config@npm:30.4.2" @@ -20279,44 +19838,6 @@ __metadata: languageName: node linkType: hard -"jest-config@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-config@npm:29.7.0" - dependencies: - "@babel/core": "npm:^7.11.6" - "@jest/test-sequencer": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-jest: "npm:^29.7.0" - chalk: "npm:^4.0.0" - ci-info: "npm:^3.2.0" - deepmerge: "npm:^4.2.2" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-circus: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-runner: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - parse-json: "npm:^5.2.0" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-json-comments: "npm:^3.1.1" - peerDependencies: - "@types/node": "*" - ts-node: ">=9.0.0" - peerDependenciesMeta: - "@types/node": - optional: true - ts-node: - optional: true - checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b - languageName: node - linkType: hard - "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -20329,18 +19850,6 @@ __metadata: languageName: node linkType: hard -"jest-diff@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-diff@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - diff-sequences: "npm:^29.6.3" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 - languageName: node - linkType: hard - "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -20350,15 +19859,6 @@ __metadata: languageName: node linkType: hard -"jest-docblock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-docblock@npm:29.7.0" - dependencies: - detect-newline: "npm:^3.0.0" - checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d - languageName: node - linkType: hard - "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -20372,19 +19872,6 @@ __metadata: languageName: node linkType: hard -"jest-each@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-each@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - pretty-format: "npm:^29.7.0" - checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda - languageName: node - linkType: hard - "jest-environment-jsdom@npm:^30.4.1": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -20416,27 +19903,6 @@ __metadata: languageName: node linkType: hard -"jest-environment-node@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-environment-node@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-mock: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 - languageName: node - linkType: hard - -"jest-get-type@npm:^29.6.3": - version: 29.6.3 - resolution: "jest-get-type@npm:29.6.3" - checksum: 10/88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 - languageName: node - linkType: hard - "jest-haste-map@npm:30.4.1": version: 30.4.1 resolution: "jest-haste-map@npm:30.4.1" @@ -20459,29 +19925,6 @@ __metadata: languageName: node linkType: hard -"jest-haste-map@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-haste-map@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/graceful-fs": "npm:^4.1.3" - "@types/node": "npm:*" - anymatch: "npm:^3.0.3" - fb-watchman: "npm:^2.0.0" - fsevents: "npm:^2.3.2" - graceful-fs: "npm:^4.2.9" - jest-regex-util: "npm:^29.6.3" - jest-util: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - micromatch: "npm:^4.0.4" - walker: "npm:^1.0.8" - dependenciesMeta: - fsevents: - optional: true - checksum: 10/8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 - languageName: node - linkType: hard - "jest-leak-detector@npm:30.4.1": version: 30.4.1 resolution: "jest-leak-detector@npm:30.4.1" @@ -20492,16 +19935,6 @@ __metadata: languageName: node linkType: hard -"jest-leak-detector@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-leak-detector@npm:29.7.0" - dependencies: - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 - languageName: node - linkType: hard - "jest-matcher-utils@npm:30.4.1": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -20514,18 +19947,6 @@ __metadata: languageName: node linkType: hard -"jest-matcher-utils@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-matcher-utils@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - pretty-format: "npm:^29.7.0" - checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 - languageName: node - linkType: hard - "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -20544,23 +19965,6 @@ __metadata: languageName: node linkType: hard -"jest-message-util@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-message-util@npm:29.7.0" - dependencies: - "@babel/code-frame": "npm:^7.12.13" - "@jest/types": "npm:^29.6.3" - "@types/stack-utils": "npm:^2.0.0" - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - micromatch: "npm:^4.0.4" - pretty-format: "npm:^29.7.0" - slash: "npm:^3.0.0" - stack-utils: "npm:^2.0.3" - checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 - languageName: node - linkType: hard - "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -20572,18 +19976,7 @@ __metadata: languageName: node linkType: hard -"jest-mock@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-mock@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - jest-util: "npm:^29.7.0" - checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c - languageName: node - linkType: hard - -"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": +"jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -20602,13 +19995,6 @@ __metadata: languageName: node linkType: hard -"jest-regex-util@npm:^29.6.3": - version: 29.6.3 - resolution: "jest-regex-util@npm:29.6.3" - checksum: 10/0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a - languageName: node - linkType: hard - "jest-resolve-dependencies@npm:30.4.2": version: 30.4.2 resolution: "jest-resolve-dependencies@npm:30.4.2" @@ -20619,16 +20005,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve-dependencies@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve-dependencies@npm:29.7.0" - dependencies: - jest-regex-util: "npm:^29.6.3" - jest-snapshot: "npm:^29.7.0" - checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 - languageName: node - linkType: hard - "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -20645,23 +20021,6 @@ __metadata: languageName: node linkType: hard -"jest-resolve@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-resolve@npm:29.7.0" - dependencies: - chalk: "npm:^4.0.0" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-pnp-resolver: "npm:^1.2.2" - jest-util: "npm:^29.7.0" - jest-validate: "npm:^29.7.0" - resolve: "npm:^1.20.0" - resolve.exports: "npm:^2.0.0" - slash: "npm:^3.0.0" - checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 - languageName: node - linkType: hard - "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -20692,35 +20051,6 @@ __metadata: languageName: node linkType: hard -"jest-runner@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runner@npm:29.7.0" - dependencies: - "@jest/console": "npm:^29.7.0" - "@jest/environment": "npm:^29.7.0" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - emittery: "npm:^0.13.1" - graceful-fs: "npm:^4.2.9" - jest-docblock: "npm:^29.7.0" - jest-environment-node: "npm:^29.7.0" - jest-haste-map: "npm:^29.7.0" - jest-leak-detector: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-resolve: "npm:^29.7.0" - jest-runtime: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - jest-watcher: "npm:^29.7.0" - jest-worker: "npm:^29.7.0" - p-limit: "npm:^3.1.0" - source-map-support: "npm:0.5.13" - checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e - languageName: node - linkType: hard - "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -20751,36 +20081,6 @@ __metadata: languageName: node linkType: hard -"jest-runtime@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-runtime@npm:29.7.0" - dependencies: - "@jest/environment": "npm:^29.7.0" - "@jest/fake-timers": "npm:^29.7.0" - "@jest/globals": "npm:^29.7.0" - "@jest/source-map": "npm:^29.6.3" - "@jest/test-result": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - chalk: "npm:^4.0.0" - cjs-module-lexer: "npm:^1.0.0" - collect-v8-coverage: "npm:^1.0.0" - glob: "npm:^7.1.3" - graceful-fs: "npm:^4.2.9" - jest-haste-map: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-mock: "npm:^29.7.0" - jest-regex-util: "npm:^29.6.3" - jest-resolve: "npm:^29.7.0" - jest-snapshot: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 - languageName: node - linkType: hard - "jest-silent-reporter@npm:^0.6.0": version: 0.6.0 resolution: "jest-silent-reporter@npm:0.6.0" @@ -20820,34 +20120,6 @@ __metadata: languageName: node linkType: hard -"jest-snapshot@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-snapshot@npm:29.7.0" - dependencies: - "@babel/core": "npm:^7.11.6" - "@babel/generator": "npm:^7.7.2" - "@babel/plugin-syntax-jsx": "npm:^7.7.2" - "@babel/plugin-syntax-typescript": "npm:^7.7.2" - "@babel/types": "npm:^7.3.3" - "@jest/expect-utils": "npm:^29.7.0" - "@jest/transform": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - babel-preset-current-node-syntax: "npm:^1.0.0" - chalk: "npm:^4.0.0" - expect: "npm:^29.7.0" - graceful-fs: "npm:^4.2.9" - jest-diff: "npm:^29.7.0" - jest-get-type: "npm:^29.6.3" - jest-matcher-utils: "npm:^29.7.0" - jest-message-util: "npm:^29.7.0" - jest-util: "npm:^29.7.0" - natural-compare: "npm:^1.4.0" - pretty-format: "npm:^29.7.0" - semver: "npm:^7.5.3" - checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 - languageName: node - linkType: hard - "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -20904,20 +20176,6 @@ __metadata: languageName: node linkType: hard -"jest-validate@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-validate@npm:29.7.0" - dependencies: - "@jest/types": "npm:^29.6.3" - camelcase: "npm:^6.2.0" - chalk: "npm:^4.0.0" - jest-get-type: "npm:^29.6.3" - leven: "npm:^3.1.0" - pretty-format: "npm:^29.7.0" - checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 - languageName: node - linkType: hard - "jest-watcher@npm:30.4.1": version: 30.4.1 resolution: "jest-watcher@npm:30.4.1" @@ -20934,22 +20192,6 @@ __metadata: languageName: node linkType: hard -"jest-watcher@npm:^29.7.0": - version: 29.7.0 - resolution: "jest-watcher@npm:29.7.0" - dependencies: - "@jest/test-result": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.2.1" - chalk: "npm:^4.0.0" - emittery: "npm:^0.13.1" - jest-util: "npm:^29.7.0" - string-length: "npm:^4.0.1" - checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 - languageName: node - linkType: hard - "jest-when@npm:^3.7.0": version: 3.7.0 resolution: "jest-when@npm:3.7.0" @@ -20983,7 +20225,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^29.4.3, jest-worker@npm:^29.7.0": +"jest-worker@npm:^29.4.3": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: @@ -20995,25 +20237,6 @@ __metadata: languageName: node linkType: hard -"jest@npm:^29.7.0": - version: 29.7.0 - resolution: "jest@npm:29.7.0" - dependencies: - "@jest/core": "npm:^29.7.0" - "@jest/types": "npm:^29.6.3" - import-local: "npm:^3.0.2" - jest-cli: "npm:^29.7.0" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a - languageName: node - linkType: hard - "jest@npm:^30.4.2": version: 30.4.2 resolution: "jest@npm:30.4.2" @@ -22588,7 +21811,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -24107,7 +23330,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.4, pirates@npm:^4.0.7": +"pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 @@ -25047,17 +24270,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": - version: 29.7.0 - resolution: "pretty-format@npm:29.7.0" - dependencies: - "@jest/schemas": "npm:^29.6.3" - ansi-styles: "npm:^5.0.0" - react-is: "npm:^18.0.0" - checksum: 10/dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb - languageName: node - linkType: hard - "pretty-time@npm:^1.1.0": version: 1.1.0 resolution: "pretty-time@npm:1.1.0" @@ -25139,7 +24351,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.0.1, prompts@npm:^2.4.2": +"prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -25258,13 +24470,6 @@ __metadata: languageName: node linkType: hard -"pure-rand@npm:^6.0.0": - version: 6.1.0 - resolution: "pure-rand@npm:6.1.0" - checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 - languageName: node - linkType: hard - "pure-rand@npm:^7.0.0": version: 7.0.1 resolution: "pure-rand@npm:7.0.1" @@ -25436,7 +24641,7 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": +"react-is-18@npm:react-is@^18.3.1": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 @@ -26000,13 +25205,6 @@ __metadata: languageName: node linkType: hard -"resolve.exports@npm:^2.0.0": - version: 2.0.3 - resolution: "resolve.exports@npm:2.0.3" - checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a - languageName: node - linkType: hard - "resolve@npm:1.22.8": version: 1.22.8 resolution: "resolve@npm:1.22.8" @@ -26020,7 +25218,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4": +"resolve@npm:^1.22.11, resolve@npm:^1.22.4": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -26047,7 +25245,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": +"resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -26348,7 +25546,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.0, semver@npm:^6.3.1": +"semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -26357,7 +25555,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1, semver@npm:^7.8.5": +"semver@npm:^7.1.1, semver@npm:^7.1.2, semver@npm:^7.3.5, semver@npm:^7.3.7, semver@npm:^7.5.3, semver@npm:^7.5.4, semver@npm:^7.6.3, semver@npm:^7.7.2, semver@npm:^7.7.3, semver@npm:^7.8.0, semver@npm:^7.8.1": version: 7.8.5 resolution: "semver@npm:7.8.5" bin: @@ -26609,7 +25807,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -26986,7 +26184,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.2, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -27031,7 +26229,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.1, string-length@npm:^4.0.2": +"string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -27646,46 +26844,6 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.2.5": - version: 29.4.12 - resolution: "ts-jest@npm:29.4.12" - dependencies: - bs-logger: "npm:^0.2.6" - fast-json-stable-stringify: "npm:^2.1.0" - handlebars: "npm:^4.7.9" - json5: "npm:^2.2.3" - lodash.memoize: "npm:^4.1.2" - make-error: "npm:^1.3.6" - semver: "npm:^7.8.5" - type-fest: "npm:^4.41.0" - yargs-parser: "npm:^21.1.1" - peerDependencies: - "@babel/core": ">=7.0.0-beta.0 <8" - "@jest/transform": ^29.0.0 || ^30.0.0 - "@jest/types": ^29.0.0 || ^30.0.0 - babel-jest: ^29.0.0 || ^30.0.0 - jest: ^29.0.0 || ^30.0.0 - jest-util: ^29.0.0 || ^30.0.0 - typescript: ">=4.3 <7" - peerDependenciesMeta: - "@babel/core": - optional: true - "@jest/transform": - optional: true - "@jest/types": - optional: true - babel-jest: - optional: true - esbuild: - optional: true - jest-util: - optional: true - bin: - ts-jest: cli.js - checksum: 10/640678eb12d0911d99b0a2ac52ebfbf0ab170ba24ab73697d71de0e00ee95f2f468299a877a5e27dadf08265d5588e8cc982a6945c16b8893657390de7baa662 - languageName: node - linkType: hard - "ts-jest@npm:^29.4.11": version: 29.4.11 resolution: "ts-jest@npm:29.4.11" @@ -29018,16 +28176,6 @@ __metadata: languageName: node linkType: hard -"write-file-atomic@npm:^4.0.2": - version: 4.0.2 - resolution: "write-file-atomic@npm:4.0.2" - dependencies: - imurmurhash: "npm:^0.1.4" - signal-exit: "npm:^3.0.7" - checksum: 10/3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 - languageName: node - linkType: hard - "write-file-atomic@npm:^5.0.0, write-file-atomic@npm:^5.0.1": version: 5.0.1 resolution: "write-file-atomic@npm:5.0.1" @@ -29247,21 +28395,6 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^17.3.1": - version: 17.7.3 - resolution: "yargs@npm:17.7.3" - dependencies: - cliui: "npm:^8.0.1" - escalade: "npm:^3.1.1" - get-caller-file: "npm:^2.0.5" - require-directory: "npm:^2.1.1" - string-width: "npm:^4.2.3" - y18n: "npm:^5.0.5" - yargs-parser: "npm:^21.1.1" - checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 - languageName: node - linkType: hard - "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 7cfb7a329197d0cfd43eb14ff59155a64a4bf9db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Sat, 1 Aug 2026 00:20:19 +0200 Subject: [PATCH 34/67] fix: messenger action type check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 946dd011545..3524210cb7f 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -47,8 +47,8 @@ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller", "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "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", From 1d983a36800729aed7d1b90753278f018f26206f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Wed, 5 Aug 2026 22:27:21 +0200 Subject: [PATCH 35/67] feat: expose as a temporary solution kycstatus and finalstatus on relay session creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/CHANGELOG.md | 3 + packages/kyc-controller/README.md | 10 ++- .../kyc-controller/src/KycController.test.ts | 69 +++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 65 ++++++++++++++--- .../kyc-controller/src/KycService.test.ts | 19 +++++ packages/kyc-controller/src/KycService.ts | 5 ++ packages/kyc-controller/src/types.ts | 7 +- 7 files changed, 167 insertions(+), 11 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index a8d3fa3569c..20eab921416 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -22,5 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add a new `polling` value to `KycSumSubStatus` and a new `sumsub.sessionStatus` field (typed as the new `KycSessionStatus`) that holds the latest polled status. - Add `KycController.getSessionStatus` for a one-off session-status fetch, and add an optional `sessionStatusPollIntervalMs` constructor option (defaults to 15000ms). - Add `KycService.getSessionStatus`, backed by the `GET /sessions/{id}/status` endpoint. +- Add handling in `KycController.startSumSub` for applicants already being processed by the vendor ([#9615](https://github.com/MetaMask/core/pull/9615)) + - When UKYC session creation reports `kycStatus: approved` with `finalStatus: pending` (the relay approved the applicant while the vendor is still finalizing), the sub-flow now stops before launching the SumSub SDK, sets the new `vendorProcessing` `KycSumSubStatus`, and surfaces a message via `statusMessage`. + - `KycService.createUkycSession` now returns optional `kycStatus` and `finalStatus` fields on `UkycSessionResponse`. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 9076fab79c7..d5b1eace11c 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -444,6 +444,7 @@ stateDiagram-v2 [*] --> idle idle --> creatingSession : startSumSub() creatingSession --> fetchingToken : createUkycSession() ok + creatingSession --> vendorProcessing : createUkycSession() kycStatus=approved, finalStatus=pending fetchingToken --> launching : createJourney() ok launching --> inProgress : onStatusChange (non-Completed) launching --> complete : onStatusChange = Completed @@ -455,6 +456,13 @@ stateDiagram-v2 launching --> failed : launcher unavailable / error ``` +> **Already processing on the vendor.** A user who already finished the journey +> can return to a session the relay has approved (`kycStatus: approved`) while +> the vendor is still finalizing its decision (`finalStatus: pending`). When +> session creation reports this, the sub-flow stops at `vendorProcessing` +> (setting `statusMessage`) instead of launching the SDK, so an already-approved +> applicant is not asked to verify again. + > **Completion is status-driven, not resolution-driven.** A resolved `launch` > is only recorded as `complete` when the SDK reported the `Completed` status > via `onStatusChange` at least once. If `launch` resolves without ever having @@ -674,6 +682,6 @@ Reference client (metamask-mobile): | `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | | `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | | `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | | `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | | `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 1b223cabf93..54fc318faf4 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1261,6 +1261,75 @@ describe('KycController', () => { }); }); + it('stops with a vendorProcessing status when the relay approved but the vendor is still pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant already finished the journey: the relay reports + // `approved` while the vendor is still finalizing (`pending`). + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ + kycStatus: 'approved', + finalStatus: 'pending', + }); + expect(controller.state.sumsub.status).toBe('vendorProcessing'); + expect(controller.state.sumsub.sessionId).toBe('sid'); + expect(controller.state.statusMessage).toMatch( + /being processed by the vendor/u, + ); + // The SDK is never launched and no journey is created for an + // already-approved applicant. + expect(handlers.createJourney).not.toHaveBeenCalled(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + + it('continues the flow when approved and the vendor is not pending', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A terminal vendor status (not `pending`) must not short-circuit. + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'approved', + }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.createJourney).toHaveBeenCalled(); + expect(launcher.launch).toHaveBeenCalled(); + }); + }); + + it('does not write vendorProcessing state when reset() runs while creating the session', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(launcher.launch).not.toHaveBeenCalled(); + }); + }); + it('aborts when the attested session server public key does not match', async () => { await withController(async ({ controller, handlers, launcher }) => { handlers.getWrappingKey.mockResolvedValue({ diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 4d3b64d67f3..fd76d325704 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -73,22 +73,45 @@ const IN_PROGRESS_PHASES: KycPhase[] = [ // until a terminal status is reached. Overridable via the constructor. const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; -// `finalStatus` values that end the polling loop. Anything else keeps polling. +// UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus` +// (the vendor-side outcome) draw from the same vocabulary, so they are defined +// once here and composed into the sets/checks below rather than repeated as +// literals. +const KYC_STATUSES = { + approved: 'approved', + completed: 'completed', + rejected: 'rejected', + failed: 'failed', + blocked: 'blocked', + pending: 'pending', +} as const; + +// `finalStatus` values that end the polling loop. Anything else (e.g. +// `KYC_STATUSES.pending`) keeps polling. const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ - 'approved', - 'completed', - 'rejected', - 'failed', - 'blocked', + KYC_STATUSES.approved, + KYC_STATUSES.completed, + KYC_STATUSES.rejected, + KYC_STATUSES.failed, + KYC_STATUSES.blocked, ]); // Terminal `finalStatus` values that represent a successful verification. Any // other terminal status resolves the sub-flow to `failed`. const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ - 'approved', - 'completed', + KYC_STATUSES.approved, + KYC_STATUSES.completed, ]); +// Session creation can report that the applicant is already approved on the +// relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still +// finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a +// non-terminal status). In that case there is nothing left for the applicant +// to do, so the sub-flow stops before launching the SDK and surfaces this +// message. +const VENDOR_PROCESSING_MESSAGE = + 'Your KYC has been submitted and is being processed by the vendor.'; + // === STATE === /** @@ -1029,6 +1052,11 @@ export class KycController extends BaseController< * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * + * If session creation reports the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. * @param params.debug - Enables SDK debug logging. @@ -1116,7 +1144,7 @@ export class KycController extends BaseController< expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), }); - const { sessionId } = await this.messenger.call( + const { sessionId, kycStatus, finalStatus } = await this.messenger.call( 'KycService:createUkycSession', { jwtToken, @@ -1129,6 +1157,25 @@ export class KycController extends BaseController< }, ); + // A user who already finished the journey can return to a session the + // relay has already approved (`kycStatus`) while the vendor is still + // finalizing its own decision (`finalStatus`). There is nothing left to + // verify, so stop here and surface a message rather than launching the + // SDK again. + console.log('===============> kycStatus', kycStatus); + console.log('===============> finalStatus', finalStatus); + if ( + kycStatus === KYC_STATUSES.approved && + finalStatus === KYC_STATUSES.pending + ) { + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'vendorProcessing'; + state.sumsub.sessionId = sessionId; + state.statusMessage = VENDOR_PROCESSING_MESSAGE; + }); + return stillCurrent ? { kycStatus, finalStatus } : {}; + } + this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'fetchingToken'; state.sumsub.sessionId = sessionId; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 645f34ee26f..f24cf2d9fa1 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -305,6 +305,25 @@ describe('KycService', () => { ).toStrictEqual(response); }); + it('returns the relay and vendor statuses when present', async () => { + const response = { + sessionId: 'sid', + kycStatus: 'approved', + finalStatus: 'pending', + }; + nock(MOCK_API_URL).post('/sessions').reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { foo: 'bar' }, + wrappedEncryptionKey, + ukycCapabilityToken, + }), + ).toStrictEqual(response); + }); + it('throws on a malformed response', async () => { nock(MOCK_API_URL).post('/sessions').reply(200, { unexpected: true }); const { service } = getService(); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 3e0dee5af57..5febe42e71b 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -155,6 +155,11 @@ export type JwksResponse = Infer; const UkycSessionResponseStruct = type({ sessionId: string(), + // The relay-side KYC decision (e.g. `approved`) and the vendor-side final + // status (e.g. `pending`) at session-creation time. Present when the applicant + // already has a session in flight; absent for a brand-new session. + kycStatus: optional(string()), + finalStatus: optional(string()), }); export type UkycSessionResponse = Infer; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 5026fbac1bf..1323aea0c1b 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -50,6 +50,10 @@ export type KycPhase = * - `polling` — the SDK finished and the controller is polling the UKYC * backend for the session's final decision (see `KycSessionStatus`). The * sub-flow resolves to `complete` or `failed` once a terminal status arrives. + * - `vendorProcessing` — session creation reported that the applicant is + * already approved on the relay (`kycStatus`) while the vendor is still + * finalizing its own decision (`finalStatus`). There is nothing left for the + * applicant to do, so the SDK is not launched; see `statusMessage`. */ export type KycSumSubStatus = | 'idle' @@ -59,7 +63,8 @@ export type KycSumSubStatus = | 'inProgress' | 'polling' | 'complete' - | 'failed'; + | 'failed' + | 'vendorProcessing'; /** * The status of a UKYC session, returned by the `GET /sessions/{id}/status` From 2664c92017b713181dfc31ba5a84163501747e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 09:45:16 +0200 Subject: [PATCH 36/67] fix: remove barrel files and useless exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController.test.ts | 13 ++++-- packages/kyc-controller/src/KycController.ts | 16 +++---- .../kyc-controller/src/KycService.test.ts | 6 +-- packages/kyc-controller/src/KycService.ts | 8 ++-- packages/kyc-controller/src/index.ts | 38 ---------------- packages/kyc-controller/src/ukyc/index.ts | 43 ------------------- 6 files changed, 23 insertions(+), 101 deletions(-) delete mode 100644 packages/kyc-controller/src/ukyc/index.ts diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 54fc318faf4..3c3078c27e3 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -13,7 +13,8 @@ import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; import { KycController } from './KycController.js'; import type { KycControllerMessenger } from './KycController.js'; import type { KycSumSubLauncher } from './types.js'; -import { verifyJwtChain, wrapEncryptionKey } from './ukyc/index.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of @@ -21,11 +22,17 @@ import { verifyJwtChain, wrapEncryptionKey } from './ukyc/index.js'; // derivation) runs for real so the controller's messenger wiring is exercised. // Return values are (re)configured per test in `withController` because the // shared jest config enables `resetMocks`. -jest.mock('./ukyc', () => { - const actual = jest.requireActual('./ukyc'); +jest.mock('./ukyc/jwtChain', () => { + const actual = jest.requireActual('./ukyc/jwtChain'); return { ...actual, verifyJwtChain: jest.fn(), + }; +}); +jest.mock('./ukyc/wrapEncryptionKey', () => { + const actual = jest.requireActual('./ukyc/wrapEncryptionKey'); + return { + ...actual, wrapEncryptionKey: jest.fn(), }; }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index fd76d325704..515d7f4682f 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -24,15 +24,13 @@ import type { KycSumSubLauncher, KycSumSubStatus, } from './types.js'; -import { - deriveClientMaterial, - getOrCreateLocalUserSecret, - signStorageAccessToken, - toBase64Url, - verifyJwtChain, - wrapEncryptionKey, -} from './ukyc/index.js'; -import type { UkycLocalUserSecretStore } from './ukyc/index.js'; +import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; +import { toBase64Url } from './ukyc/encoding.js'; +import { verifyJwtChain } from './ukyc/jwtChain.js'; +import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; +import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; +import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; +import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index f24cf2d9fa1..ebfd13f326b 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -8,12 +8,12 @@ import nock, { cleanAll } from 'nock'; import type { KycServiceMessenger } from './KycService.js'; import { KycService } from './KycService.js'; +import { UKYC_LOCAL_USER_SECRET_SIZE_BYTES } from './ukyc/constants.js'; +import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { - deriveClientMaterial, encodeStorageAccessTokenForHeader, signStorageAccessToken, - UKYC_LOCAL_USER_SECRET_SIZE_BYTES, -} from './ukyc/index.js'; +} from './ukyc/storageAccessToken.js'; const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; const MOCK_FRACTAL_URL = 'https://fractal.dev-api.cx.metamask.io'; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 5febe42e71b..7ff76fb04cc 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -20,11 +20,9 @@ import { import { alpha2ToAlpha3 } from './countryCodes.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { KycDisclaimer, KycSessionStatus } from './types.js'; -import { - encodeStorageAccessTokenForHeader, - UKYC_JWKS_PATH, -} from './ukyc/index.js'; -import type { UkycStorageAccessToken } from './ukyc/index.js'; +import { UKYC_JWKS_PATH } from './ukyc/constants.js'; +import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; +import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; // === GENERAL === diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 1d1346c3c5b..3daf76d4135 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -83,41 +83,3 @@ export type { KycSumSubStatus, KycVendor, } from './types.js'; - -export { - base64UrlToBytes, - buildWrappedRelayPayload, - canonicalizeJson, - deriveClientMaterial, - encodeClientMaterial, - encodeStorageAccessTokenForHeader, - getOrCreateLocalUserSecret, - hasLocalUserSecret, - loadLocalUserSecret, - signStorageAccessToken, - toBase64Url, - UKYC_DERIVED_KEY_SIZES, - UKYC_JWKS_PATH, - UKYC_KDF_INFO, - UKYC_LOCAL_USER_SECRET_PATH, - UKYC_LOCAL_USER_SECRET_SIZE_BYTES, - UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, - UKYC_STORAGE_ACCESS_TOKEN_VERSION, - verifyJwtChain, - wrapEncryptionKey, - wrapUserKey, -} from './ukyc/index.js'; -export type { - EncodedUkycClientMaterial, - Jwk, - JwtChainPayload, - SignStorageAccessTokenParams, - UkycClientMaterial, - UkycLocalUserSecretStore, - UkycStorageAccessToken, - UkycStorageAccessTokenPayload, - UkycStorageOperation, - UkycTokenPresenter, - UkycWrappedRelayPayload, - WrappedEncryptionKeyParts, -} from './ukyc/index.js'; diff --git a/packages/kyc-controller/src/ukyc/index.ts b/packages/kyc-controller/src/ukyc/index.ts deleted file mode 100644 index 3f09304c29f..00000000000 --- a/packages/kyc-controller/src/ukyc/index.ts +++ /dev/null @@ -1,43 +0,0 @@ -export { - UKYC_DERIVED_KEY_SIZES, - UKYC_JWKS_PATH, - UKYC_KDF_INFO, - UKYC_LOCAL_USER_SECRET_PATH, - UKYC_LOCAL_USER_SECRET_SIZE_BYTES, - UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, - UKYC_STORAGE_ACCESS_TOKEN_VERSION, -} from './constants.js'; -export { base64UrlToBytes, toBase64Url } from './encoding.js'; -export { - deriveClientMaterial, - encodeClientMaterial, -} from './deriveClientMaterial.js'; -export type { - EncodedUkycClientMaterial, - UkycClientMaterial, -} from './deriveClientMaterial.js'; -export { verifyJwtChain } from './jwtChain.js'; -export type { Jwk, JwtChainPayload } from './jwtChain.js'; -export { - getOrCreateLocalUserSecret, - hasLocalUserSecret, - loadLocalUserSecret, -} from './localUserSecret.js'; -export type { UkycLocalUserSecretStore } from './localUserSecret.js'; -export { - canonicalizeJson, - encodeStorageAccessTokenForHeader, - signStorageAccessToken, -} from './storageAccessToken.js'; -export type { - SignStorageAccessTokenParams, - UkycStorageAccessToken, - UkycStorageAccessTokenPayload, - UkycStorageOperation, - UkycTokenPresenter, -} from './storageAccessToken.js'; -export { wrapEncryptionKey } from './wrapEncryptionKey.js'; -export type { WrappedEncryptionKeyParts } from './wrapEncryptionKey.js'; -export { wrapUserKey } from './wrapUserKey.js'; -export { buildWrappedRelayPayload } from './wrappedRelayPayload.js'; -export type { UkycWrappedRelayPayload } from './wrappedRelayPayload.js'; From 26b1ded11b030eef14d53bcd67f4039a1cee4cb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 11:26:32 +0200 Subject: [PATCH 37/67] feat: refactor kyc service to base service pattern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/CHANGELOG.md | 19 +- packages/kyc-controller/package.json | 2 + packages/kyc-controller/src/KycService.ts | 246 ++++++++++++------ packages/kyc-controller/src/index.ts | 3 + packages/kyc-controller/tsconfig.build.json | 1 + packages/kyc-controller/tsconfig.json | 1 + yarn.lock | 260 +++++++++++++++++++- 7 files changed, 440 insertions(+), 92 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 164c07fba17..71dc41e7c9f 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,20 +10,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - - `KycService` performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. + - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. -- Add automatic post-authentication continuation to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) - - `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state. - - When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior). -- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#9615](https://github.com/MetaMask/core/pull/9615)) -- Add UKYC session-status polling to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) - - After the SumSub SDK reports completion, the controller now polls the UKYC backend for the session's final verification decision instead of treating the SDK result as final. Polling stops on a terminal `finalStatus` (`approved`, `completed`, `rejected`, `failed`, `blocked`), resolving the sub-flow to `complete` (for `approved` / `completed`) or `failed` (otherwise). Polling is also cleared on `reset` and when a new sub-flow starts. - - Add a new `polling` value to `KycSumSubStatus` and a new `sumsub.sessionStatus` field (typed as the new `KycSessionStatus`) that holds the latest polled status. - - Add `KycController.getSessionStatus` for a one-off session-status fetch, and add an optional `sessionStatusPollIntervalMs` constructor option (defaults to 15000ms). - - Add `KycService.getSessionStatus`, backed by the `GET /sessions/{id}/status` endpoint. -- Add handling in `KycController.startSumSub` for applicants already being processed by the vendor ([#9615](https://github.com/MetaMask/core/pull/9615)) - - When UKYC session creation reports `kycStatus: approved` with `finalStatus: pending` (the relay approved the applicant while the vendor is still finalizing), the sub-flow now stops before launching the SumSub SDK, sets the new `vendorProcessing` `KycSumSubStatus`, and surfaces a message via `statusMessage`. - - `KycService.createUkycSession` now returns optional `kycStatus` and `finalStatus` fields on `UkycSessionResponse`. + - Add automatic post-authentication continuation to `KycController` + - Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API + - Add UKYC session-status polling to `KycController` + - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 3524210cb7f..e010d737694 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -57,6 +57,7 @@ }, "dependencies": { "@metamask/base-controller": "^9.1.0", + "@metamask/base-data-service": "^0.1.3", "@metamask/controller-utils": "^12.3.0", "@metamask/geolocation-controller": "^1.0.0", "@metamask/messenger": "^2.0.0", @@ -67,6 +68,7 @@ "@noble/curves": "^1.9.2", "@noble/hashes": "^1.8.0", "@scure/base": "^1.2.6", + "@tanstack/query-core": "^4.43.0", "reselect": "^5.1.1", "tweetnacl": "^1.0.3" }, diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 7ff76fb04cc..73eec07f440 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -1,8 +1,11 @@ +import { BaseDataService } from '@metamask/base-data-service'; import type { - CreateServicePolicyOptions, - ServicePolicy, -} from '@metamask/controller-utils'; -import { createServicePolicy, HttpError } from '@metamask/controller-utils'; + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; @@ -16,6 +19,9 @@ import { StructError, type, } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { Duration, inMilliseconds } from '@metamask/utils'; +import type { QueryClientConfig } from '@tanstack/query-core'; import { alpha2ToAlpha3 } from './countryCodes.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; @@ -55,10 +61,18 @@ const MESSENGER_EXPOSED_METHODS = [ 'getSessionStatus', ] as const; +/** + * Invalidates cached queries serviced by {@link KycService}. + */ +export type KycServiceInvalidateQueriesAction = + DataServiceInvalidateQueriesAction; + /** * Actions that {@link KycService} exposes to other consumers. */ -export type KycServiceActions = KycServiceMethodActions; +export type KycServiceActions = + | KycServiceMethodActions + | KycServiceInvalidateQueriesAction; /** * Actions from other messengers that {@link KycService} calls. @@ -67,10 +81,24 @@ type AllowedActions = | AuthenticationControllerGetBearerTokenAction | GeolocationControllerGetGeolocationAction; +/** + * Published when {@link KycService}'s cache is updated. + */ +export type KycServiceCacheUpdatedEvent = + DataServiceCacheUpdatedEvent; + +/** + * Published when a single key within {@link KycService}'s cache is updated. + */ +export type KycServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + /** * Events that {@link KycService} exposes to other consumers. */ -export type KycServiceEvents = never; +export type KycServiceEvents = + | KycServiceCacheUpdatedEvent + | KycServiceGranularCacheUpdatedEvent; /** * Events from other messengers that {@link KycService} subscribes to. @@ -107,6 +135,12 @@ export type KycServiceOptions = { * {@link KycService.fetchJwks}. */ fractalEncryptionBaseUrl?: string; + /** + * Shared configuration applied to all queries exposed by the service (e.g. a + * default `staleTime`/`cacheTime`). Each data service gets its own + * `QueryClient`. + */ + queryClientConfig?: QueryClientConfig; policyOptions?: CreateServicePolicyOptions; }; @@ -233,20 +267,24 @@ export type GetSessionStatusParams = { * identity + document-verification flow. It is stateless and platform-agnostic: * HTTP is performed through an injected `fetch`, and the auth bearer token and * geolocation come from other controllers via the messenger. + * + * It extends {@link BaseDataService}, so every request is routed through + * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit + * breaker) and its result is exposed via the service's `QueryClient`. Read-only + * endpoints (`fetchDisclaimers`, `fetchJwks`) are cached with a `staleTime`; + * the session-creating and status-polling endpoints opt out of caching + * (`staleTime`/`cacheTime` of `0`) so they never serve a stale result. */ -export class KycService { - readonly name: typeof serviceName; - - readonly #messenger: KycServiceMessenger; - +export class KycService extends BaseDataService< + typeof serviceName, + KycServiceMessenger +> { readonly #fetch: typeof fetch; readonly #baseUrl: string; readonly #fractalEncryptionBaseUrl: string; - readonly #policy: ServicePolicy; - /** * Constructs a new KycService. * @@ -258,6 +296,8 @@ export class KycService { * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. + * @param options.queryClientConfig - Shared configuration for all queries + * exposed by the service. * @param options.policyOptions - Options for the request service policy. */ constructor({ @@ -266,15 +306,19 @@ export class KycService { env, baseUrl, fractalEncryptionBaseUrl, - policyOptions, + queryClientConfig = {}, + policyOptions = {}, }: KycServiceOptions) { - this.name = serviceName; - this.#messenger = messenger; + super({ + name: serviceName, + messenger, + queryClientConfig, + policyOptions, + }); this.#fetch = fetchFunction; this.#baseUrl = baseUrl ?? KYC_API_URLS[env]; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; - this.#policy = createServicePolicy(policyOptions ?? {}); - this.#messenger.registerMethodActionHandlers( + this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, ); @@ -288,7 +332,7 @@ export class KycService { * @throws If the country cannot be determined or mapped. */ async getGeoCountry(): Promise { - const location = await this.#messenger.call( + const location = await this.messenger.call( 'GeolocationController:getGeolocation', ); // Guard nullish/empty geolocation with the documented domain error rather @@ -328,7 +372,11 @@ export class KycService { }): Promise { const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl); url.searchParams.set('country', country); - const data = await this.#request(url, { method: 'GET' }); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchDisclaimers`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); return this.#validateResponse( data, DisclaimersResponseStruct, @@ -346,9 +394,21 @@ export class KycService { params: CreateSessionParams, ): Promise> { const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); - const data = await this.#request(url, { - method: 'POST', - body: JSON.stringify(params), + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createSession`, + params.email, + params.termsAcceptedAt, + params.disclaimerIds, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify(params), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -368,13 +428,26 @@ export class KycService { params: CheckKycRequiredParams, ): Promise<{ kycRequired: boolean }> { const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl); - const data = await this.#request(url, { - method: 'POST', - body: JSON.stringify({ - accessToken: params.accessToken, - country: params.country, - capabilities: params.capabilities ?? [{ product: 'ramps' }], - }), + const capabilities = params.capabilities ?? [{ product: 'ramps' }]; + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:checkKycRequired`, + params.accessToken, + params.country, + capabilities, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + accessToken: params.accessToken, + country: params.country, + capabilities, + }), + }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + cacheTime: 0, }); const { required } = this.#validateResponse( data, @@ -402,11 +475,18 @@ export class KycService { params: GetWrappingKeyParams, ): Promise { const url = new URL('/wrapping-key', this.#baseUrl); - const data = await this.#request(url, { - method: 'POST', - body: JSON.stringify({ - sessionClientPublicKey: params.sessionClientPublicKey, - }), + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getWrappingKey`, params.sessionClientPublicKey], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + sessionClientPublicKey: params.sessionClientPublicKey, + }), + }), + // A per-session key exchange must always run fresh. + staleTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -431,11 +511,12 @@ export class KycService { ); } const url = new URL(UKYC_JWKS_PATH, this.#fractalEncryptionBaseUrl); - const data = await this.#request( - url, - { method: 'GET' }, - { authenticated: false }, - ); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchJwks`, this.#fractalEncryptionBaseUrl], + queryFn: async () => + this.#requestJson(url, { method: 'GET' }, { authenticated: false }), + staleTime: inMilliseconds(1, Duration.Hour), + }); return this.#validateResponse(data, JwksResponseStruct, 'JWKS'); } @@ -452,18 +533,28 @@ export class KycService { params: CreateUkycSessionParams, ): Promise { const url = new URL('/sessions', this.#baseUrl); - const data = await this.#request(url, { - method: 'POST', - body: JSON.stringify({ - vendorId: 'moonpay', - vendorUserId: 'mockedId', - jwtToken: params.jwtToken, - vendorMetadata: params.vendorMetadata, - wrappedEncryptionKey: params.wrappedEncryptionKey, - ukycCapabilityToken: encodeStorageAccessTokenForHeader( - params.ukycCapabilityToken, - ), - }), + const data = await this.fetchQuery({ + queryKey: [ + `${this.name}:createUkycSession`, + params.wrappedEncryptionKey.sessionId, + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + vendorMetadata: params.vendorMetadata, + wrappedEncryptionKey: params.wrappedEncryptionKey, + ukycCapabilityToken: encodeStorageAccessTokenForHeader( + params.ukycCapabilityToken, + ), + }), + }), + // A session-creating mutation must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -486,8 +577,12 @@ export class KycService { `/sessions/${encodeURIComponent(sessionId)}/journey`, this.#baseUrl, ); - const data = await this.#request(url, { - method: 'POST', + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createJourney`, sessionId], + queryFn: async () => this.#requestJson(url, { method: 'POST' }), + // Journeys are (re)created on demand; do not reuse a cached token. + staleTime: 0, + cacheTime: 0, }); return this.#validateResponse( data, @@ -511,7 +606,13 @@ export class KycService { `/sessions/${encodeURIComponent(params.sessionId)}/status`, this.#baseUrl, ); - const data = await this.#request(url, { method: 'GET' }); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:getSessionStatus`, params.sessionId], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for a terminal decision, so it must always be fresh. + staleTime: 0, + cacheTime: 0, + }); return this.#validateResponse( data, SessionStatusResponseStruct, @@ -556,9 +657,11 @@ export class KycService { } /** - * Performs a JSON request wrapped in the service policy. + * Performs a single JSON request. * - * Requests are authenticated with the wallet bearer token by default; pass + * This is meant to be used as the `queryFn` for {@link fetchQuery}, which + * wraps it in the shared service policy (retries, circuit breaker). Requests + * are authenticated with the wallet bearer token by default; pass * `{ authenticated: false }` for calls to services that do not expect it * (e.g. the Fractal JWKS endpoint). * @@ -569,11 +672,11 @@ export class KycService { * to `true`. * @returns The parsed JSON response. */ - async #request( + async #requestJson( url: URL, init: RequestInit, options: { authenticated?: boolean } = {}, - ): Promise { + ): Promise { const { authenticated = true } = options; const headers: Record = {}; @@ -585,7 +688,7 @@ export class KycService { } if (authenticated) { - const bearerToken = await this.#messenger.call( + const bearerToken = await this.messenger.call( 'AuthenticationController:getBearerToken', ); assert(bearerToken, string()); @@ -597,20 +700,17 @@ export class KycService { headers.Authorization = `Bearer ${bearerToken}`; } - const response = await this.#policy.execute(async () => { - const localResponse = await this.#fetch(url.toString(), { - ...init, - headers, - }); - if (!localResponse.ok) { - throw new HttpError( - localResponse.status, - `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, - ); - } - return localResponse; + const response = await this.#fetch(url.toString(), { + ...init, + headers, }); + if (!response.ok) { + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'`, + ); + } - return response.json(); + return (await response.json()) as Json; } } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 3daf76d4135..9b40cdfca55 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -38,8 +38,11 @@ export type { GetWrappingKeyParams, JwksResponse, KycServiceActions, + KycServiceCacheUpdatedEvent, KycServiceEnvironment, KycServiceEvents, + KycServiceGranularCacheUpdatedEvent, + KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, UkycSessionResponse, diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json index b36e81be15e..d355169e16c 100644 --- a/packages/kyc-controller/tsconfig.build.json +++ b/packages/kyc-controller/tsconfig.build.json @@ -7,6 +7,7 @@ }, "references": [ { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../base-data-service/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../geolocation-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json index be54252e245..8fd2d917930 100644 --- a/packages/kyc-controller/tsconfig.json +++ b/packages/kyc-controller/tsconfig.json @@ -5,6 +5,7 @@ }, "references": [ { "path": "../base-controller" }, + { "path": "../base-data-service" }, { "path": "../controller-utils" }, { "path": "../geolocation-controller" }, { "path": "../messenger" }, diff --git a/yarn.lock b/yarn.lock index d881e1c4698..f914716b641 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7548,7 +7548,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^27.1.1, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^27.1.0, @metamask/keyring-controller@npm:^27.1.1, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -7681,12 +7681,29 @@ __metadata: resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^1.0.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" + chokidar-cli: "npm:^3.0.0" deepmerge: "npm:^4.2.2" jest: "npm:^30.4.2" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" ts-jest: "npm:^29.4.11" tsx: "npm:^4.20.5" + tweetnacl: "npm:^1.0.3" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" @@ -8596,6 +8613,30 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-sync-controller@npm:^28.3.0": + version: 28.3.0 + resolution: "@metamask/profile-sync-controller@npm:28.3.0" + dependencies: + "@metamask/address-book-controller": "npm:^7.1.2" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/keyring-controller": "npm:^27.1.0" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/snaps-controllers": "npm:^19.0.0" + "@metamask/snaps-sdk": "npm:^11.0.0" + "@metamask/snaps-utils": "npm:^12.1.2" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/hashes": "npm:^1.8.0" + immer: "npm:^9.0.6" + loglevel: "npm:^1.8.1" + siwe: "npm:^2.3.2" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/a0fa6dad8a183bcd41907e7ab5d35fcb321bd1c7040125120deebc20b3d809a0e0f076feb2e80ef70b9ce1330138a8d1c8d8b5eea18086cd765ae4301a32e2ba + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^29.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -11002,7 +11043,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -13727,6 +13768,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -13748,6 +13796,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -14715,7 +14772,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -14869,7 +14926,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -15057,6 +15128,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -15153,6 +15235,15 @@ __metadata: languageName: node linkType: hard +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -15162,6 +15253,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -15919,6 +16017,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" @@ -16355,6 +16460,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -17714,6 +17826,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -17984,7 +18105,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -19291,6 +19412,13 @@ __metadata: languageName: node linkType: hard +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + "is-fullwidth-code-point@npm:^3.0.0": version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" @@ -20556,6 +20684,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -20632,6 +20770,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -22781,7 +22926,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -22808,6 +22953,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -23054,6 +23208,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -25011,6 +25172,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -25511,6 +25679,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -26107,6 +26282,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -26166,6 +26352,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -27853,6 +28048,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -27947,6 +28149,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^6.2.0": version: 6.2.0 resolution: "wrap-ansi@npm:6.2.0" @@ -28120,6 +28333,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -28157,6 +28377,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -28179,6 +28409,24 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0" From 7e5464ce65dccf2ae5e6bf6cf2661d5066fec923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 11:27:45 +0200 Subject: [PATCH 38/67] feat: add comments about vendorProcessing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- .../kyc-controller/src/KycController-method-action-types.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 548cb505a33..2586ea9c637 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -139,6 +139,11 @@ export type KycControllerGetKycStatusAction = { * 5. fetches the SumSub applicant access token; and * 6. presents the SDK via the injected launcher. * + * If session creation reports the applicant is already approved on the relay + * while the vendor is still finalizing (`kycStatus: approved`, + * `finalStatus: pending`), the sub-flow stops at step 4 with a + * `vendorProcessing` status and a message rather than launching the SDK. + * * @param params - Optional parameters. * @param params.locale - BCP-47 locale for the SDK UI. * @param params.debug - Enables SDK debug logging. From f17b9ea3a3ccc8cf431259e697d634a34f3703e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 16:11:01 +0200 Subject: [PATCH 39/67] fix: remove type cast MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/KycController.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 515d7f4682f..a981752e092 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1450,7 +1450,7 @@ export class KycController extends BaseController< * * `sumsub.result` is typed as the recursive `Json`, and expanding * `Draft` (which happens whenever an updater touches `sumsub.result`) - * trips TypeScript's "type instantiation is excessively deep" guard. By + * can trip TypeScript's "type instantiation is excessively deep" guard. By * typing the callback parameter as the plain {@link KycControllerState} * instead of Immer's `Draft`, we avoid expanding the draft type while keeping * the same mutate-in-place semantics (the underlying value is still the Immer @@ -1460,7 +1460,10 @@ export class KycController extends BaseController< */ #applyUpdate(updater: (state: KycControllerState) => void): void { this.update((state) => { - updater(state as unknown as KycControllerState); + // `@ts-expect-error` cannot be used: ts-bridge does not surface + // TS2589, so the directive is unused and fails the build. + // type issue only happens at the IDE level. + updater(state); }); } From 11969e2f1a67fab82e26280344fecb85824482a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 23:18:21 +0200 Subject: [PATCH 40/67] feat: move architecture info to a dedicated .md file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/ARCHITECTURE.md | 663 +++++++++++++++++++++++ packages/kyc-controller/README.md | 668 +----------------------- packages/kyc-controller/package.json | 2 +- 3 files changed, 666 insertions(+), 667 deletions(-) create mode 100644 packages/kyc-controller/ARCHITECTURE.md diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md new file mode 100644 index 00000000000..265a538fbcf --- /dev/null +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -0,0 +1,663 @@ +## Architecture + +`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns +the end-to-end KYC / identity-verification flow used across MetaMask clients +(mobile, extension, web). It hides the vendor implementation (currently +**MoonPay** for identity + **SumSub** for document verification) behind a +vendor-neutral, per-product surface consumed by features such as **ramps** and +**card**. + +This document explains: + +- The package's internal building blocks and responsibilities. +- How the pieces communicate (messenger actions, injected adapters). +- The identity flow as a state machine and an end-to-end sequence. +- The encrypted frame message protocol and crypto. +- How the **metamask-mobile** client wires everything together on the client + side. + +--- + +### 1. Design principles + +The package is built around a few deliberate constraints: + +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | + +--- + +### 2. Component overview + +The package splits cleanly into a **stateful orchestrator** (`KycController`), a +**stateless HTTP client** (`KycService`), and supporting modules (crypto, +selectors, types). + +```mermaid +graph TB + subgraph pkg["@metamask/kyc-controller"] + direction TB + Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Service["KycService
(stateless)
HTTP + response validation"] + Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] + Selectors["selectors.ts
memoized reselect selectors"] + Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Country["countryCodes.ts
alpha-2 → alpha-3"] + end + + subgraph deps["External MetaMask dependencies"] + Base["@metamask/base-controller"] + Msgr["@metamask/messenger"] + CU["@metamask/controller-utils
createServicePolicy, HttpError"] + Geo["GeolocationController"] + Auth["AuthenticationController
(profile-sync)"] + end + + subgraph vendor["Vendor backends (HTTP / frames)"] + UKYC["Universal KYC API
kyc-api.cx.metamask.io"] + Frames["MoonPay frames
blocks.moonpay.com"] + SumSubSDK["SumSub SDK
(native / web)"] + end + + Controller -->|"decryptCredentials()"| Crypto + Controller -->|"messenger.call(KycService:*)"| Service + Controller -.->|"injected launcher"| SumSubSDK + Controller -->|"builds frame URLs
handles frame messages"| Frames + + Service -->|"createServicePolicy / HttpError"| CU + Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo + Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth + Service -->|"fetch()"| UKYC + + Controller --- Base + Controller --- Msgr + Service --- Msgr + Selectors -.->|"read"| Controller +``` + +#### 2.1 `KycController` + +- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. +- Holds **all flow state** (see [§3](#3-state-shape)). +- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — + never persisted, used only for the frame key exchange. +- Registers its public methods as messenger actions via + `registerMethodActionHandlers`. +- Calls `KycService` exclusively **through the messenger** (`KycService:*` + actions), never a direct reference. +- Delegates SumSub SDK presentation to an injected `sumsubLauncher` + (`KycSumSubLauncher`). +- When the flow is scoped to a product (passed to `initialize` / + `acceptTermsAndStartSession` and stored as `activeProduct`), automatically + runs the KYC-required check once authenticated and chains into document + verification when KYC is required — no extra consumer calls needed. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, +`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, +`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, +`startSumSub`, `reset`. + +#### 2.2 `KycService` + +- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) + backend. +- Base URL derived from `env` (`production` / `development`) or an explicit + `baseUrl` override. +- Every request is wrapped in a **service policy** (`createServicePolicy`) for + retries/circuit-breaking, and carries a **bearer token** obtained from + `AuthenticationController:getBearerToken`. +- Every response is validated with **superstruct** before being returned; + malformed responses throw a descriptive error. +- Resolves the customer's country from `GeolocationController:getGeolocation` + and maps alpha-2 → alpha-3. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, +`createUkycSession`, `createJourney`. + +Endpoints: + +| Method | HTTP | Endpoint | Purpose | +| ------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | +| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | + +### 2.3 `crypto.ts` + +Implements the Check/Auth frame credential decryption: + +1. Client generates an X25519 keypair; the public key (hex) is added to the + frame URL. +2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. +3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then + `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the + ciphertext (which includes the 16-byte tag). IV must be 12 bytes. + +It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), +and hex-or-base64 binary fields. + +#### 2.4 `selectors.ts` + +Memoized `reselect` selectors over `KycControllerState`: +`selectKycPhase`, `selectKycSumSub`, and the parametric +`selectIsKycRequiredForProduct(product)`. + +--- + +### 3. State shape + +```mermaid +classDiagram + class KycControllerState { + +KycPhase phase + +string statusMessage + +string error + +string email + +string termsAcceptedAt [persisted] + +string[] acceptedDisclaimerIds [persisted] + +KycDisclaimer[] disclaimers + +string disclaimersError + +string geoCountry + +string sessionToken [secret] + +string accessToken [secret] + +string moonpayCustomerId + +KycProduct activeProduct + +Record kycRequiredByProduct [persisted] + +string lastCheckedAt [persisted] + +SumSubState sumsub + } + class SumSubState { + +KycSumSubStatus status + +Json result + +string sessionId + +string applicantAccessToken + } + KycControllerState --> SumSubState : sumsub +``` + +> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> `T | null` in the source; `Record` is `Partial>`. +> Types are simplified above for diagram readability. + +State metadata highlights (`kycControllerMetadata`): + +- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, + `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow + can skip already-accepted terms and reuse cached results. +- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, + `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. +- Additional non-state secrets kept **off** the state object entirely: the + X25519 private key (`#keypair`) and the Auth-frame client token + (`#authClientToken`). + +--- + +### 4. The identity flow (phase state machine) + +`KycPhase` models the linear identity flow. Each transition is driven by a +controller method or an incoming frame message. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> terms : initialize() (no saved terms) + idle --> session : initialize() (saved terms + email) + + terms --> session : acceptTermsAndStartSession() + session --> check : createSession() ok + session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) + + check --> form : Check frame → active (already authenticated) + check --> auth : Check frame → connectionRequired (needs OTP) + check --> terms : Check frame → termsAcceptanceRequired + + auth --> form : Auth frame → active (OTP verified) + auth --> terms : Auth frame → termsAcceptanceRequired + + form --> submit : checkKycRequired()
(auto when a product is set) + submit --> done : kyc-required response ok + submit --> error : request failed + + check --> error : unexpected status / decrypt failure + auth --> error : unexpected status + done --> [*] + error --> idle : reset() + done --> idle : reset() +``` + +> When the flow is scoped to a product (a `product` is passed to `initialize` +> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the +> KYC-required check (`form → submit → done`) with no user interaction, and — if +> KYC is required — automatically launches the SumSub document-verification +> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops +> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. + +> **`initialize` never tears down an active flow.** If `phase` is already one of +> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a +> repeat `initialize` is a **no-op** — it will not create a new session, clear +> tokens, or reset `activeProduct`. Call `reset()` first to start over. + +> **`reset()` is callable from any phase and supersedes in-flight work.** In +> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, +> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any +> still-pending async step (geolocation, disclaimers, session creation, the +> KYC-required check, or the SumSub sub-flow) discards its result instead of +> writing it onto the now-idle controller. + +Phase meanings (from `types.ts`): + +| Phase | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | + +--- + +### 5. End-to-end sequence + +This sequence shows the full happy path including the two frames and the SumSub +hand-off. The **client transport** (WebView on mobile, iframe on web) is +generic — it only forwards raw frame messages to `handleFrameMessage` and posts +back any returned `reply`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Client UI + transport
(WebView/iframe) + participant Ctrl as KycController + participant Svc as KycService + participant Geo as GeolocationController + participant API as UKYC API + participant Frame as MoonPay Check/Auth frame + participant Launcher as SumSub launcher (injected) + + User->>Ctrl: initialize({ email, product }) + Ctrl->>Svc: getGeoCountry() + Svc->>Geo: getGeolocation() + Note over Svc: map alpha-2 → alpha-3 locally + Ctrl->>Svc: fetchDisclaimers({ country }) + Svc->>API: GET /disclaimers + Ctrl-->>UI: phase = terms (+ disclaimers) + + User->>Ctrl: acceptTermsAndStartSession({ email }) + Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) + Svc->>API: POST /sessions + Ctrl-->>UI: phase = check (+ sessionToken) + + UI->>Ctrl: buildCheckFrameUrl() + Ctrl-->>UI: URL (sessionToken + publicKey) + UI->>Frame: load Check frame (invisible) + Frame-->>UI: handshake + UI->>Ctrl: handleFrameMessage(handshake) + Ctrl-->>UI: reply = ack + UI->>Frame: post ack + Frame-->>UI: complete (status + encrypted credentials) + UI->>Ctrl: handleFrameMessage(complete) + Note over Ctrl: decryptCredentials() → accessToken / clientToken + + alt Check → connectionRequired + Ctrl-->>UI: phase = auth + UI->>Frame: load Auth frame (visible, OTP) + Frame-->>UI: complete (active + credentials) + UI->>Ctrl: handleFrameMessage(complete) + end + + Ctrl-->>UI: phase = form (accessToken set) + + Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) + Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) + Svc->>API: POST /kyc-required + Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) + + opt kycRequired === true → auto-launch document verification + Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken }) + Svc->>API: POST /sessions + Ctrl->>Svc: createJourney(sessionId) + Svc->>API: POST /sessions/{id}/journey + Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) + Launcher-->>Ctrl: SDK result + Ctrl-->>UI: sumsub.status = complete (+ result) + end +``` + +> The KYC-required check and the document-verification launch after `form` are +> driven by the controller itself, not the user — the flow captures the +> `product` at `initialize` and continues automatically. If `initialize` is +> called without a `product`, the flow stops at `form` and the consumer triggers +> `checkKycRequired` (and later `startSumSub`) explicitly. + +--- + +### 6. Frame message protocol & crypto + +The Check, Auth and Reset frames all speak a small `postMessage` protocol. +`KycController.handleFrameMessage` implements the identity portion; the client +transport is responsible only for delivering messages and injecting replies. + +```mermaid +sequenceDiagram + autonumber + participant Frame as MoonPay frame + participant UI as Client transport + participant Ctrl as KycController + + Frame->>UI: { kind: "handshake", meta:{channelId} } + UI->>Ctrl: handleFrameMessage({ message }) + Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } + UI->>Frame: postMessage(ack) + + Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } + UI->>Ctrl: handleFrameMessage({ message }) + Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) + Ctrl->>Ctrl: apply outcome → next phase +``` + +Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. + +> **Phase-guarded intake.** A `complete` is only processed when the flow is +> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` +> while `phase === 'auth'`. Because both outcome handlers advance `phase` to +> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` +> (delivered once the flow has moved on) is dropped before any state is touched, +> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. +> Frame messages are external input and are not covered by the `#generation` +> guard used for the controller's own async steps, so this boundary check is how +> late frame posts are neutralized. + +Credential decryption (`crypto.ts`): + +```mermaid +graph LR + A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] + B --> C["HKDF-SHA256
key (32 bytes)"] + C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] + D --> E["JSON credentials
{ accessToken?, clientToken? }"] +``` + +Check-frame outcomes (`#handleCheckOutcome`): + +- `active` + `accessToken` → phase `form` (already authenticated). +- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +Auth-frame outcomes (`#handleAuthOutcome`): + +- `active` + `accessToken` → phase `form`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +--- + +### 7. SumSub sub-flow + +The document-verification sub-flow tracks its own status independently of the +identity `phase`, and delegates the actual SDK presentation to the injected +launcher. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> creatingSession : startSumSub() + creatingSession --> fetchingToken : createUkycSession() ok + creatingSession --> vendorProcessing : createUkycSession() kycStatus=approved, finalStatus=pending + fetchingToken --> launching : createJourney() ok + launching --> inProgress : onStatusChange (non-Completed) + launching --> complete : onStatusChange = Completed + inProgress --> complete : onStatusChange = Completed + launching --> failed : resolves without a Completed status + inProgress --> failed : resolves without a Completed status + creatingSession --> failed : error + fetchingToken --> failed : error + launching --> failed : launcher unavailable / error +``` + +> **Already processing on the vendor.** A user who already finished the journey +> can return to a session the relay has approved (`kycStatus: approved`) while +> the vendor is still finalizing its decision (`finalStatus: pending`). When +> session creation reports this, the sub-flow stops at `vendorProcessing` +> (setting `statusMessage`) instead of launching the SDK, so an already-approved +> applicant is not asked to verify again. + +> **Completion is status-driven, not resolution-driven.** A resolved `launch` +> is only recorded as `complete` when the SDK reported the `Completed` status +> via `onStatusChange` at least once. If `launch` resolves without ever having +> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success +> outcome), the controller records `failed` — so consumers never mistake an +> unfinished flow for a verified one. + +The `KycSumSubLauncher` interface (injected per client): + +```ts +type KycSumSubLauncher = { + isAvailable(): boolean; + launch(params: KycSumSubLaunchParams): Promise>; +}; +``` + +`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the +controller re-runs `createJourney` to refresh — but **refuses to refresh +after a `reset()`**, throwing instead so a still-open SDK cannot keep an +orphaned UKYC session alive), and an `onStatusChange` callback that the +controller maps into `sumsub.status`. + +--- + +### 8. Messenger wiring + +Both classes are messenger-driven. The controller depends on the service's +actions; the service depends on auth + geolocation actions from other +controllers. + +```mermaid +graph LR + subgraph CtrlMsgr["KycControllerMessenger"] + C_own["Own actions:
KycController:getState + 12 methods"] + C_ext["Allowed (delegated):
KycService:*"] + end + subgraph SvcMsgr["KycServiceMessenger"] + S_own["Own actions:
KycService: 6 methods"] + S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] + end + + C_ext -.delegates.-> S_own + S_ext -.delegates.-> Auth["AuthenticationController"] + S_ext -.delegates.-> Geo["GeolocationController"] +``` + +- `KycController` emits `KycController:stateChange` and exposes + `KycController:getState` plus its method actions. +- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call + the service. +- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation + actions. + +--- + +### 9. Client-side usage (metamask-mobile) + +The mobile app is a reference consumer. It wires the controller/service into the +Engine, injects a React Native SumSub launcher, bridges WebView frame messages, +and reads state through Redux selectors. The **package stays free of any of +this** — all React/native/WebView code lives in the app. + +```mermaid +graph TB + subgraph app["metamask-mobile"] + direction TB + subgraph engine["Engine wiring"] + CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] + SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] + CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] + SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] + Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] + end + subgraph ui["UI layer"] + Hook["useKycFlow.ts
binds controller ↔ React"] + Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] + Reset["useMoonpayReset.ts
Reset frame"] + Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] + end + subgraph redux["Redux"] + Sel["selectors/kycController.ts
wraps core selectors"] + end + end + + subgraph core["@metamask/kyc-controller"] + KC["KycController"] + KS["KycService"] + end + + CInit --> KC + SInit --> KS + CInit --> Launcher + Launcher -. injected .-> KC + CMsgr --> KC + SMsgr --> KS + + Hook -->|"Engine.context.KycController.*"| KC + Hook -->|"useSelector"| Sel + Sel -->|"state.engine.backgroundState.KycController"| KC + Frame -->|"raw frame message"| Hook + Hook -->|"handleFrameMessage()"| KC + Demo --> Hook + Demo --> Frame + Demo --> Reset +``` + +#### 9.1 Engine wiring + +- **`kyc-controller-init.ts`** constructs `KycController` with the persisted + state slice and injects `reactNativeSumSubLauncher`. +- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an + `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override. +- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to + the controller's messenger. +- **`kyc-service-messenger.ts`** delegates + `AuthenticationController:getBearerToken` and + `GeolocationController:getGeolocation` to the service's messenger. + +#### 9.2 SumSub launcher adapter + +`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: + +- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). +- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so + merely wiring the controller never loads the native module — important for + Jest / Expo Go), initializes the SDK with the applicant token, and forwards + `onStatusChanged` / token-expiration callbacks back to the controller. + +#### 9.3 React binding — `useKycFlow` + +A thin hook that: + +- Reads controller state from Redux via the `selectors/kycController.ts` + selectors. +- Forwards user intents to controller actions through + `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, + `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). +- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as + the phase changes. +- Bridges WebView frame messages into `handleFrameMessage` and posts back the + returned `reply`. +- Keeps view-only concerns (email input, debug log, frame visibility) in local + React state. + +#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` + +- Injects a `postMessage` bridge into the frame that forwards the frame's + outbound messages to React Native via `window.ReactNativeWebView.postMessage`. +- **Validates the origin** (`https://blocks.moonpay.com`) before handing a + message to the controller. +- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on + both `document` and `window` (platform quirk between iOS WKWebView and Android + System WebView). +- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user + toggles it in the debug panel; the Auth frame is rendered visibly for OTP. + +#### 9.5 Redux selectors + +`selectors/kycController.ts` wraps the package's core selectors and reads the +slice at `state.engine.backgroundState.KycController`, exposing app-friendly +selectors (`selectKycPhase`, `selectKycSumSub`, +`selectIsKycRequiredForProduct(product)`, plus per-field selectors). + +--- + +### 10. Boundaries & responsibilities summary + +```mermaid +graph LR + subgraph shared["Shared package (platform-agnostic)"] + A1["Flow orchestration + state"] + A2["HTTP + response validation"] + A3["Crypto (X25519 / AES-GCM)"] + A4["Frame message protocol"] + A5["Selectors + vendor-neutral types"] + end + subgraph client["Client (per platform)"] + B1["Engine/DI wiring"] + B2["WebView / iframe transport"] + B3["SumSub SDK launcher"] + B4["Auth token + geolocation providers"] + B5["UI + Redux binding"] + end + shared -. injected adapters .- client +``` + +| Concern | Owner | +| ------------------------------------ | ------------------------------------------- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | + +--- + +### Appendix — key source files + +| File | Responsibility | +| ---------------------- | ----------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | + +Reference client (metamask-mobile): + +| File | Responsibility | +| -------------------------------------------------------------- | --------------------------------------- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index d5b1eace11c..32ee194a139 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -12,7 +12,7 @@ or ## Development -To rebuild the package automatically whenever you change a source file, run the `build:watch` script: +To rebuild the package automatically whenever you change a source file, run the `build:watch` script from core repository root folder: `yarn workspace @metamask/kyc-controller run build:watch` @@ -20,668 +20,4 @@ This watches `src/**/*.ts` and re-runs the build on each change (it also perform ## Contributing -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). - -## Architecture - -`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns -the end-to-end KYC / identity-verification flow used across MetaMask clients -(mobile, extension, web). It hides the vendor implementation (currently -**MoonPay** for identity + **SumSub** for document verification) behind a -vendor-neutral, per-product surface consumed by features such as **ramps** and -**card**. - -This document explains: - -- The package's internal building blocks and responsibilities. -- How the pieces communicate (messenger actions, injected adapters). -- The identity flow as a state machine and an end-to-end sequence. -- The encrypted frame message protocol and crypto. -- How the **metamask-mobile** client wires everything together on the client - side. - ---- - -### 1. Design principles - -The package is built around a few deliberate constraints: - -| Principle | How it shows up in the code | -| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | -| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | -| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | -| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | -| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | - ---- - -### 2. Component overview - -The package splits cleanly into a **stateful orchestrator** (`KycController`), a -**stateless HTTP client** (`KycService`), and supporting modules (crypto, -selectors, types). - -```mermaid -graph TB - subgraph pkg["@metamask/kyc-controller"] - direction TB - Controller["KycController
(BaseController)
state + orchestration + frame protocol"] - Service["KycService
(stateless)
HTTP + response validation"] - Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] - Selectors["selectors.ts
memoized reselect selectors"] - Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] - Country["countryCodes.ts
alpha-2 → alpha-3"] - end - - subgraph deps["External MetaMask dependencies"] - Base["@metamask/base-controller"] - Msgr["@metamask/messenger"] - CU["@metamask/controller-utils
createServicePolicy, HttpError"] - Geo["GeolocationController"] - Auth["AuthenticationController
(profile-sync)"] - end - - subgraph vendor["Vendor backends (HTTP / frames)"] - UKYC["Universal KYC API
kyc-api.cx.metamask.io"] - Frames["MoonPay frames
blocks.moonpay.com"] - SumSubSDK["SumSub SDK
(native / web)"] - end - - Controller -->|"decryptCredentials()"| Crypto - Controller -->|"messenger.call(KycService:*)"| Service - Controller -.->|"injected launcher"| SumSubSDK - Controller -->|"builds frame URLs
handles frame messages"| Frames - - Service -->|"createServicePolicy / HttpError"| CU - Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo - Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth - Service -->|"fetch()"| UKYC - - Controller --- Base - Controller --- Msgr - Service --- Msgr - Selectors -.->|"read"| Controller -``` - -#### 2.1 `KycController` - -- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. -- Holds **all flow state** (see [§3](#3-state-shape)). -- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — - never persisted, used only for the frame key exchange. -- Registers its public methods as messenger actions via - `registerMethodActionHandlers`. -- Calls `KycService` exclusively **through the messenger** (`KycService:*` - actions), never a direct reference. -- Delegates SumSub SDK presentation to an injected `sumsubLauncher` - (`KycSumSubLauncher`). -- When the flow is scoped to a product (passed to `initialize` / - `acceptTermsAndStartSession` and stored as `activeProduct`), automatically - runs the KYC-required check once authenticated and chains into document - verification when KYC is required — no extra consumer calls needed. - -Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): - -`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, -`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, -`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, -`startSumSub`, `reset`. - -#### 2.2 `KycService` - -- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) - backend. -- Base URL derived from `env` (`production` / `development`) or an explicit - `baseUrl` override. -- Every request is wrapped in a **service policy** (`createServicePolicy`) for - retries/circuit-breaking, and carries a **bearer token** obtained from - `AuthenticationController:getBearerToken`. -- Every response is validated with **superstruct** before being returned; - malformed responses throw a descriptive error. -- Resolves the customer's country from `GeolocationController:getGeolocation` - and maps alpha-2 → alpha-3. - -Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): - -`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, -`createUkycSession`, `createJourney`. - -Endpoints: - -| Method | HTTP | Endpoint | Purpose | -| ------------------- | ------ | --------------------------------------- | ----------------------------------------------------------------------- | -| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | -| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | -| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | -| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | -| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow (wrapped key + read-only `ukyc_capability_token`) | -| `createJourney` | `POST` | `/sessions/{id}/journey` | Create verification journey → applicant token | - -### 2.3 `crypto.ts` - -Implements the Check/Auth frame credential decryption: - -1. Client generates an X25519 keypair; the public key (hex) is added to the - frame URL. -2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. -3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then - `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the - ciphertext (which includes the 16-byte tag). IV must be 12 bytes. - -It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), -and hex-or-base64 binary fields. - -#### 2.4 `selectors.ts` - -Memoized `reselect` selectors over `KycControllerState`: -`selectKycPhase`, `selectKycSumSub`, and the parametric -`selectIsKycRequiredForProduct(product)`. - ---- - -### 3. State shape - -```mermaid -classDiagram - class KycControllerState { - +KycPhase phase - +string statusMessage - +string error - +string email - +string termsAcceptedAt [persisted] - +string[] acceptedDisclaimerIds [persisted] - +KycDisclaimer[] disclaimers - +string disclaimersError - +string geoCountry - +string sessionToken [secret] - +string accessToken [secret] - +string moonpayCustomerId - +KycProduct activeProduct - +Record kycRequiredByProduct [persisted] - +string lastCheckedAt [persisted] - +SumSubState sumsub - } - class SumSubState { - +KycSumSubStatus status - +Json result - +string sessionId - +string applicantAccessToken - } - KycControllerState --> SumSubState : sumsub -``` - -> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as -> `T | null` in the source; `Record` is `Partial>`. -> Types are simplified above for diagram readability. - -State metadata highlights (`kycControllerMetadata`): - -- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, - `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow - can skip already-accepted terms and reuse cached results. -- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, - `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. -- Additional non-state secrets kept **off** the state object entirely: the - X25519 private key (`#keypair`) and the Auth-frame client token - (`#authClientToken`). - ---- - -### 4. The identity flow (phase state machine) - -`KycPhase` models the linear identity flow. Each transition is driven by a -controller method or an incoming frame message. - -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> terms : initialize() (no saved terms) - idle --> session : initialize() (saved terms + email) - - terms --> session : acceptTermsAndStartSession() - session --> check : createSession() ok - session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) - - check --> form : Check frame → active (already authenticated) - check --> auth : Check frame → connectionRequired (needs OTP) - check --> terms : Check frame → termsAcceptanceRequired - - auth --> form : Auth frame → active (OTP verified) - auth --> terms : Auth frame → termsAcceptanceRequired - - form --> submit : checkKycRequired()
(auto when a product is set) - submit --> done : kyc-required response ok - submit --> error : request failed - - check --> error : unexpected status / decrypt failure - auth --> error : unexpected status - done --> [*] - error --> idle : reset() - done --> idle : reset() -``` - -> When the flow is scoped to a product (a `product` is passed to `initialize` -> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the -> KYC-required check (`form → submit → done`) with no user interaction, and — if -> KYC is required — automatically launches the SumSub document-verification -> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops -> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. - -> **`initialize` never tears down an active flow.** If `phase` is already one of -> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a -> repeat `initialize` is a **no-op** — it will not create a new session, clear -> tokens, or reset `activeProduct`. Call `reset()` first to start over. - -> **`reset()` is callable from any phase and supersedes in-flight work.** In -> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, -> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any -> still-pending async step (geolocation, disclaimers, session creation, the -> KYC-required check, or the SumSub sub-flow) discards its result instead of -> writing it onto the now-idle controller. - -Phase meanings (from `types.ts`): - -| Phase | Meaning | -| --------- | ----------------------------------------------------------------------------------------------------------- | -| `idle` | Nothing started. | -| `terms` | Waiting for the customer to accept vendor terms. | -| `session` | Creating the vendor session. | -| `check` | Running the **invisible** connection-check frame. | -| `auth` | Running the **visible** authentication (email OTP) frame. | -| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | -| `submit` | Submitting the KYC-required check. | -| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | -| `error` | Halted — see `error`. | - ---- - -### 5. End-to-end sequence - -This sequence shows the full happy path including the two frames and the SumSub -hand-off. The **client transport** (WebView on mobile, iframe on web) is -generic — it only forwards raw frame messages to `handleFrameMessage` and posts -back any returned `reply`. - -```mermaid -sequenceDiagram - autonumber - actor User - participant UI as Client UI + transport
(WebView/iframe) - participant Ctrl as KycController - participant Svc as KycService - participant Geo as GeolocationController - participant API as UKYC API - participant Frame as MoonPay Check/Auth frame - participant Launcher as SumSub launcher (injected) - - User->>Ctrl: initialize({ email, product }) - Ctrl->>Svc: getGeoCountry() - Svc->>Geo: getGeolocation() - Note over Svc: map alpha-2 → alpha-3 locally - Ctrl->>Svc: fetchDisclaimers({ country }) - Svc->>API: GET /disclaimers - Ctrl-->>UI: phase = terms (+ disclaimers) - - User->>Ctrl: acceptTermsAndStartSession({ email }) - Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) - Svc->>API: POST /sessions - Ctrl-->>UI: phase = check (+ sessionToken) - - UI->>Ctrl: buildCheckFrameUrl() - Ctrl-->>UI: URL (sessionToken + publicKey) - UI->>Frame: load Check frame (invisible) - Frame-->>UI: handshake - UI->>Ctrl: handleFrameMessage(handshake) - Ctrl-->>UI: reply = ack - UI->>Frame: post ack - Frame-->>UI: complete (status + encrypted credentials) - UI->>Ctrl: handleFrameMessage(complete) - Note over Ctrl: decryptCredentials() → accessToken / clientToken - - alt Check → connectionRequired - Ctrl-->>UI: phase = auth - UI->>Frame: load Auth frame (visible, OTP) - Frame-->>UI: complete (active + credentials) - UI->>Ctrl: handleFrameMessage(complete) - end - - Ctrl-->>UI: phase = form (accessToken set) - - Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) - Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) - Svc->>API: POST /kyc-required - Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) - - opt kycRequired === true → auto-launch document verification - Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata, wrappedEncryptionKey, ukycCapabilityToken }) - Svc->>API: POST /sessions - Ctrl->>Svc: createJourney(sessionId) - Svc->>API: POST /sessions/{id}/journey - Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) - Launcher-->>Ctrl: SDK result - Ctrl-->>UI: sumsub.status = complete (+ result) - end -``` - -> The KYC-required check and the document-verification launch after `form` are -> driven by the controller itself, not the user — the flow captures the -> `product` at `initialize` and continues automatically. If `initialize` is -> called without a `product`, the flow stops at `form` and the consumer triggers -> `checkKycRequired` (and later `startSumSub`) explicitly. - ---- - -### 6. Frame message protocol & crypto - -The Check, Auth and Reset frames all speak a small `postMessage` protocol. -`KycController.handleFrameMessage` implements the identity portion; the client -transport is responsible only for delivering messages and injecting replies. - -```mermaid -sequenceDiagram - autonumber - participant Frame as MoonPay frame - participant UI as Client transport - participant Ctrl as KycController - - Frame->>UI: { kind: "handshake", meta:{channelId} } - UI->>Ctrl: handleFrameMessage({ message }) - Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } - UI->>Frame: postMessage(ack) - - Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } - UI->>Ctrl: handleFrameMessage({ message }) - Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) - Ctrl->>Ctrl: apply outcome → next phase -``` - -Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. - -> **Phase-guarded intake.** A `complete` is only processed when the flow is -> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` -> while `phase === 'auth'`. Because both outcome handlers advance `phase` to -> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` -> (delivered once the flow has moved on) is dropped before any state is touched, -> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. -> Frame messages are external input and are not covered by the `#generation` -> guard used for the controller's own async steps, so this boundary check is how -> late frame posts are neutralized. - -Credential decryption (`crypto.ts`): - -```mermaid -graph LR - A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] - B --> C["HKDF-SHA256
key (32 bytes)"] - C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] - D --> E["JSON credentials
{ accessToken?, clientToken? }"] -``` - -Check-frame outcomes (`#handleCheckOutcome`): - -- `active` + `accessToken` → phase `form` (already authenticated). -- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. -- `termsAcceptanceRequired` → clear saved terms, phase `terms`. -- anything else → `error`. - -Auth-frame outcomes (`#handleAuthOutcome`): - -- `active` + `accessToken` → phase `form`. -- `termsAcceptanceRequired` → clear saved terms, phase `terms`. -- anything else → `error`. - ---- - -### 7. SumSub sub-flow - -The document-verification sub-flow tracks its own status independently of the -identity `phase`, and delegates the actual SDK presentation to the injected -launcher. - -```mermaid -stateDiagram-v2 - [*] --> idle - idle --> creatingSession : startSumSub() - creatingSession --> fetchingToken : createUkycSession() ok - creatingSession --> vendorProcessing : createUkycSession() kycStatus=approved, finalStatus=pending - fetchingToken --> launching : createJourney() ok - launching --> inProgress : onStatusChange (non-Completed) - launching --> complete : onStatusChange = Completed - inProgress --> complete : onStatusChange = Completed - launching --> failed : resolves without a Completed status - inProgress --> failed : resolves without a Completed status - creatingSession --> failed : error - fetchingToken --> failed : error - launching --> failed : launcher unavailable / error -``` - -> **Already processing on the vendor.** A user who already finished the journey -> can return to a session the relay has approved (`kycStatus: approved`) while -> the vendor is still finalizing its decision (`finalStatus: pending`). When -> session creation reports this, the sub-flow stops at `vendorProcessing` -> (setting `statusMessage`) instead of launching the SDK, so an already-approved -> applicant is not asked to verify again. - -> **Completion is status-driven, not resolution-driven.** A resolved `launch` -> is only recorded as `complete` when the SDK reported the `Completed` status -> via `onStatusChange` at least once. If `launch` resolves without ever having -> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success -> outcome), the controller records `failed` — so consumers never mistake an -> unfinished flow for a verified one. - -The `KycSumSubLauncher` interface (injected per client): - -```ts -type KycSumSubLauncher = { - isAvailable(): boolean; - launch(params: KycSumSubLaunchParams): Promise>; -}; -``` - -`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the -controller re-runs `createJourney` to refresh — but **refuses to refresh -after a `reset()`**, throwing instead so a still-open SDK cannot keep an -orphaned UKYC session alive), and an `onStatusChange` callback that the -controller maps into `sumsub.status`. - ---- - -### 8. Messenger wiring - -Both classes are messenger-driven. The controller depends on the service's -actions; the service depends on auth + geolocation actions from other -controllers. - -```mermaid -graph LR - subgraph CtrlMsgr["KycControllerMessenger"] - C_own["Own actions:
KycController:getState + 12 methods"] - C_ext["Allowed (delegated):
KycService:*"] - end - subgraph SvcMsgr["KycServiceMessenger"] - S_own["Own actions:
KycService: 6 methods"] - S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] - end - - C_ext -.delegates.-> S_own - S_ext -.delegates.-> Auth["AuthenticationController"] - S_ext -.delegates.-> Geo["GeolocationController"] -``` - -- `KycController` emits `KycController:stateChange` and exposes - `KycController:getState` plus its method actions. -- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call - the service. -- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation - actions. - ---- - -### 9. Client-side usage (metamask-mobile) - -The mobile app is a reference consumer. It wires the controller/service into the -Engine, injects a React Native SumSub launcher, bridges WebView frame messages, -and reads state through Redux selectors. The **package stays free of any of -this** — all React/native/WebView code lives in the app. - -```mermaid -graph TB - subgraph app["metamask-mobile"] - direction TB - subgraph engine["Engine wiring"] - CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] - SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] - CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] - SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] - Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] - end - subgraph ui["UI layer"] - Hook["useKycFlow.ts
binds controller ↔ React"] - Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] - Reset["useMoonpayReset.ts
Reset frame"] - Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] - end - subgraph redux["Redux"] - Sel["selectors/kycController.ts
wraps core selectors"] - end - end - - subgraph core["@metamask/kyc-controller"] - KC["KycController"] - KS["KycService"] - end - - CInit --> KC - SInit --> KS - CInit --> Launcher - Launcher -. injected .-> KC - CMsgr --> KC - SMsgr --> KS - - Hook -->|"Engine.context.KycController.*"| KC - Hook -->|"useSelector"| Sel - Sel -->|"state.engine.backgroundState.KycController"| KC - Frame -->|"raw frame message"| Hook - Hook -->|"handleFrameMessage()"| KC - Demo --> Hook - Demo --> Frame - Demo --> Reset -``` - -#### 9.1 Engine wiring - -- **`kyc-controller-init.ts`** constructs `KycController` with the persisted - state slice and injects `reactNativeSumSubLauncher`. -- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an - `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override. -- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to - the controller's messenger. -- **`kyc-service-messenger.ts`** delegates - `AuthenticationController:getBearerToken` and - `GeolocationController:getGeolocation` to the service's messenger. - -#### 9.2 SumSub launcher adapter - -`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: - -- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). -- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so - merely wiring the controller never loads the native module — important for - Jest / Expo Go), initializes the SDK with the applicant token, and forwards - `onStatusChanged` / token-expiration callbacks back to the controller. - -#### 9.3 React binding — `useKycFlow` - -A thin hook that: - -- Reads controller state from Redux via the `selectors/kycController.ts` - selectors. -- Forwards user intents to controller actions through - `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, - `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). -- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as - the phase changes. -- Bridges WebView frame messages into `handleFrameMessage` and posts back the - returned `reply`. -- Keeps view-only concerns (email input, debug log, frame visibility) in local - React state. - -#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` - -- Injects a `postMessage` bridge into the frame that forwards the frame's - outbound messages to React Native via `window.ReactNativeWebView.postMessage`. -- **Validates the origin** (`https://blocks.moonpay.com`) before handing a - message to the controller. -- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on - both `document` and `window` (platform quirk between iOS WKWebView and Android - System WebView). -- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user - toggles it in the debug panel; the Auth frame is rendered visibly for OTP. - -#### 9.5 Redux selectors - -`selectors/kycController.ts` wraps the package's core selectors and reads the -slice at `state.engine.backgroundState.KycController`, exposing app-friendly -selectors (`selectKycPhase`, `selectKycSumSub`, -`selectIsKycRequiredForProduct(product)`, plus per-field selectors). - ---- - -### 10. Boundaries & responsibilities summary - -```mermaid -graph LR - subgraph shared["Shared package (platform-agnostic)"] - A1["Flow orchestration + state"] - A2["HTTP + response validation"] - A3["Crypto (X25519 / AES-GCM)"] - A4["Frame message protocol"] - A5["Selectors + vendor-neutral types"] - end - subgraph client["Client (per platform)"] - B1["Engine/DI wiring"] - B2["WebView / iframe transport"] - B3["SumSub SDK launcher"] - B4["Auth token + geolocation providers"] - B5["UI + Redux binding"] - end - shared -. injected adapters .- client -``` - -| Concern | Owner | -| ------------------------------------ | ------------------------------------------- | -| Flow phase machine & state | `KycController` (shared) | -| UKYC HTTP + validation + retries | `KycService` (shared) | -| Credential decryption / key exchange | `crypto.ts` (shared) | -| Frame message semantics | `KycController.handleFrameMessage` (shared) | -| Frame **transport** (WebView/iframe) | Client | -| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | -| Auth bearer token / geolocation | Other controllers (via messenger) | -| Persistence of state | Client (base-controller persistence) | - ---- - -### Appendix — key source files - -| File | Responsibility | -| ---------------------- | ----------------------------------------------------- | -| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | -| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | -| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | -| `src/selectors.ts` | Memoized selectors over controller state. | -| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | -| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | -| `src/index.ts` | Public exports (no barrel wildcards). | - -Reference client (metamask-mobile): - -| File | Responsibility | -| -------------------------------------------------------------- | --------------------------------------- | -| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | -| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | -| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | -| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | -| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | -| `app/selectors/kycController.ts` | Redux selectors. | +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). \ No newline at end of file diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index e010d737694..811bf1e9725 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -42,7 +42,7 @@ "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", "build:docs": "typedoc", - "build:watch": "chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", + "build:watch": "yarn build && chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", "changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller", "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-controller", "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", From 52875bee785804cf1069a21a9480b712c8e0fc11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 23:26:27 +0200 Subject: [PATCH 41/67] feat: remove trailing logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/src/KycController.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index a981752e092..6c35d3225cb 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -1160,8 +1160,6 @@ export class KycController extends BaseController< // finalizing its own decision (`finalStatus`). There is nothing left to // verify, so stop here and surface a message rather than launching the // SDK again. - console.log('===============> kycStatus', kycStatus); - console.log('===============> finalStatus', finalStatus); if ( kycStatus === KYC_STATUSES.approved && finalStatus === KYC_STATUSES.pending From ade32c87892b7352fddf42acd1e0a333c9b0f09f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Thu, 6 Aug 2026 23:39:14 +0200 Subject: [PATCH 42/67] feat: baseUrl is now mandatory for KYC service initialization --- .../kyc-controller/src/KycService.test.ts | 17 +++++++----- packages/kyc-controller/src/KycService.ts | 26 +++++-------------- packages/kyc-controller/src/index.ts | 1 - 3 files changed, 18 insertions(+), 26 deletions(-) diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index ebfd13f326b..2b3e57e86a0 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -426,8 +426,8 @@ describe('KycService', () => { }); }); - describe('baseUrl override', () => { - it('uses the provided baseUrl instead of the env-derived URL', async () => { + describe('baseUrl', () => { + it('uses the provided baseUrl for requests', async () => { const customUrl = 'https://kyc-api.local.test'; const disclaimers = [ { id: '1', display_name: 'Terms', url: 'https://t' }, @@ -442,6 +442,12 @@ describe('KycService', () => { disclaimers, ); }); + + it('throws when baseUrl is empty', () => { + expect(() => getService({ baseUrl: '' })).toThrow( + 'KycService: baseUrl is required', + ); + }); }); describe('messenger actions', () => { @@ -474,7 +480,7 @@ type RootMessenger = Messenger< * @param args.bearerToken - The bearer token the auth handler returns. * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. - * @param args.baseUrl - When provided, overrides the env-derived base URL. + * @param args.baseUrl - Base URL of the KYC API. * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the * option so the service falls back to an empty string. * @returns The service, root messenger, and service messenger. @@ -483,7 +489,7 @@ function getService({ bearerToken = 'test-bearer', geolocation = 'US-NY', defaultPolicy = false, - baseUrl, + baseUrl = MOCK_API_URL, // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, @@ -525,9 +531,8 @@ function getService({ const service = new KycService({ fetch, messenger, - env: 'development', + baseUrl, ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), - ...(baseUrl ? { baseUrl } : {}), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 73eec07f440..460105d4d4a 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -37,16 +37,6 @@ import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; */ export const serviceName = 'KycService'; -/** - * The supported environments for the Universal KYC API. - */ -export type KycServiceEnvironment = 'production' | 'development'; - -const KYC_API_URLS: Record = { - production: 'https://kyc-api.cx.metamask.io', - development: 'https://kyc-api.dev-api.cx.metamask.io', -}; - // === MESSENGER === const MESSENGER_EXPOSED_METHODS = [ @@ -121,13 +111,10 @@ export type KycServiceMessenger = Messenger< export type KycServiceOptions = { messenger: KycServiceMessenger; fetch: typeof fetch; - env: KycServiceEnvironment; /** - * Overrides the base URL derived from `env`. When provided, this value is - * used verbatim as the base URL for all requests, which is useful for - * targeting a local or staging KYC API. + * Mandatory value that sets the base url to KYC api */ - baseUrl?: string; + baseUrl: string; /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is @@ -291,8 +278,7 @@ export class KycService extends BaseDataService< * @param options - The constructor options. * @param options.messenger - The messenger suited for this service. * @param options.fetch - A function used to make HTTP requests. - * @param options.env - The environment; determines the base URL. - * @param options.baseUrl - Overrides the base URL derived from `env`. + * @param options.baseUrl - Base URL of the KYC API * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. @@ -303,7 +289,6 @@ export class KycService extends BaseDataService< constructor({ messenger, fetch: fetchFunction, - env, baseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, @@ -316,7 +301,10 @@ export class KycService extends BaseDataService< policyOptions, }); this.#fetch = fetchFunction; - this.#baseUrl = baseUrl ?? KYC_API_URLS[env]; + if (!baseUrl) { + throw new Error('KycService: baseUrl is required'); + } + this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.messenger.registerMethodActionHandlers( this, diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 9b40cdfca55..d20f17916e9 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -39,7 +39,6 @@ export type { JwksResponse, KycServiceActions, KycServiceCacheUpdatedEvent, - KycServiceEnvironment, KycServiceEvents, KycServiceGranularCacheUpdatedEvent, KycServiceInvalidateQueriesAction, From 2be2775b82c35eea852c34fd1cdeff5c3fb3fc37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 7 Aug 2026 00:09:52 +0200 Subject: [PATCH 43/67] feat: remove base64ToBytes from crypto ts and move encoding file one level up --- packages/kyc-controller/src/KycController.ts | 2 +- packages/kyc-controller/src/crypto.ts | 28 +++++-------------- .../src/{ukyc => }/encoding.test.ts | 2 +- .../kyc-controller/src/{ukyc => }/encoding.ts | 3 +- .../src/ukyc/deriveClientMaterial.ts | 2 +- .../kyc-controller/src/ukyc/jwtChain.test.ts | 2 +- packages/kyc-controller/src/ukyc/jwtChain.ts | 2 +- .../src/ukyc/storageAccessToken.ts | 2 +- .../src/ukyc/wrapEncryptionKey.test.ts | 2 +- .../src/ukyc/wrapEncryptionKey.ts | 2 +- .../kyc-controller/src/ukyc/wrapUserKey.ts | 2 +- .../src/ukyc/wrappedRelayPayload.ts | 2 +- 12 files changed, 18 insertions(+), 33 deletions(-) rename packages/kyc-controller/src/{ukyc => }/encoding.test.ts (96%) rename packages/kyc-controller/src/{ukyc => }/encoding.ts (91%) diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 6c35d3225cb..3abb1eb9dcf 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -25,7 +25,7 @@ import type { KycSumSubStatus, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { toBase64Url } from './ukyc/encoding.js'; +import { toBase64Url } from './encoding.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts index e54ea730915..50012f016d3 100644 --- a/packages/kyc-controller/src/crypto.ts +++ b/packages/kyc-controller/src/crypto.ts @@ -14,8 +14,9 @@ * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes) * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag) * - * This module is platform-agnostic: it uses `@noble/*` + `@scure/base` and - * avoids `Buffer` / `atob` so it runs unchanged on mobile, extension, and web. + * This module is platform-agnostic: it uses `@noble/*` and `@metamask/utils` + * (via the shared encoding helpers) and avoids `Buffer` / `atob` so it runs + * unchanged on mobile, extension, and web. */ import { gcm } from '@noble/ciphers/aes'; @@ -23,7 +24,8 @@ import { x25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; -import { base64 } from '@scure/base'; + +import { base64UrlToBytes } from './encoding.js'; /** * An X25519 keypair used for the Check/Auth frame key exchange. @@ -91,22 +93,6 @@ export function generateKeyPair(): X25519KeyPair { }; } -/** - * Decode a base64 / base64url string to bytes without relying on `atob` or - * `Buffer`. - * - * @param value - The (possibly url-safe, possibly unpadded) base64 string. - * @returns The decoded bytes. - */ -function base64ToBytes(value: string): Uint8Array { - const normalized = value.replace(/-/gu, '+').replace(/_/gu, '/'); - const padded = normalized.padEnd( - normalized.length + ((4 - (normalized.length % 4)) % 4), - '=', - ); - return base64.decode(padded); -} - /** * Decode a binary envelope field that may be hex or base64. * @@ -121,7 +107,7 @@ function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array { if (isHex) { return hexToBytes(value); } - return base64ToBytes(value); + return base64UrlToBytes(value); } /** @@ -154,7 +140,7 @@ function normalizeEnvelope( } else { let decodedText: string | null = null; try { - decodedText = new TextDecoder().decode(base64ToBytes(trimmed)); + decodedText = new TextDecoder().decode(base64UrlToBytes(trimmed)); } catch { decodedText = null; } diff --git a/packages/kyc-controller/src/ukyc/encoding.test.ts b/packages/kyc-controller/src/encoding.test.ts similarity index 96% rename from packages/kyc-controller/src/ukyc/encoding.test.ts rename to packages/kyc-controller/src/encoding.test.ts index 43bc0199dd1..bd21da29df3 100644 --- a/packages/kyc-controller/src/ukyc/encoding.test.ts +++ b/packages/kyc-controller/src/encoding.test.ts @@ -2,7 +2,7 @@ import { areUint8ArraysEqual } from '@metamask/utils'; import { base64UrlToBytes, toBase64Url } from './encoding.js'; -describe('UKYC encoding', () => { +describe('encoding', () => { describe('toBase64Url', () => { it('produces unpadded, url-safe base64', () => { // 0xFB 0xFF encodes to "+/8=" in standard base64, exercising both the diff --git a/packages/kyc-controller/src/ukyc/encoding.ts b/packages/kyc-controller/src/encoding.ts similarity index 91% rename from packages/kyc-controller/src/ukyc/encoding.ts rename to packages/kyc-controller/src/encoding.ts index 116edc83ebf..f9fb9f265a2 100644 --- a/packages/kyc-controller/src/ukyc/encoding.ts +++ b/packages/kyc-controller/src/encoding.ts @@ -1,8 +1,7 @@ import { base64ToBytes, bytesToBase64 } from '@metamask/utils'; /** - * Encoding helpers shared across the UKYC client-material and - * storage-authorization modules. + * Shared base64url encoding helpers used by frame crypto and UKYC modules. * * These are platform-agnostic: they rely on `@metamask/utils` rather than * `Buffer` / `atob`, so they run unchanged on mobile, extension, and web. diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts index d0e621d91ba..59ffa68d024 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -4,7 +4,7 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; -import { toBase64Url } from './encoding.js'; +import { toBase64Url } from '../encoding.js'; /** * Derives UKYC client material from the root `local_user_secret` using diff --git a/packages/kyc-controller/src/ukyc/jwtChain.test.ts b/packages/kyc-controller/src/ukyc/jwtChain.test.ts index e8d89c151b5..735da283d21 100644 --- a/packages/kyc-controller/src/ukyc/jwtChain.test.ts +++ b/packages/kyc-controller/src/ukyc/jwtChain.test.ts @@ -1,7 +1,7 @@ import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; -import { toBase64Url } from './encoding.js'; +import { toBase64Url } from '../encoding.js'; import type { Jwk } from './jwtChain.js'; import { verifyJwtChain } from './jwtChain.js'; diff --git a/packages/kyc-controller/src/ukyc/jwtChain.ts b/packages/kyc-controller/src/ukyc/jwtChain.ts index d3ae4def629..ebb40d93d48 100644 --- a/packages/kyc-controller/src/ukyc/jwtChain.ts +++ b/packages/kyc-controller/src/ukyc/jwtChain.ts @@ -1,7 +1,7 @@ import { bytesToString } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; -import { base64UrlToBytes } from './encoding.js'; +import { base64UrlToBytes } from '../encoding.js'; /** * Verifies the `jwtChain` returned by the Fractal encryption service against diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index 2e2d2490acb..ac2af14b16d 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -6,7 +6,7 @@ import { UKYC_STORAGE_ACCESS_TOKEN_VERSION, } from './constants.js'; import type { UkycClientMaterial } from './deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; +import { toBase64Url } from '../encoding.js'; /** * Mints `storage_access_token` capabilities — the client-signed, scoped, diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts index 97df6e24b1c..bfd3225d27b 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.test.ts @@ -1,7 +1,7 @@ import { areUint8ArraysEqual } from '@metamask/utils'; import { box } from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding.js'; +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; import { wrapEncryptionKey } from './wrapEncryptionKey.js'; const DATA_ENCRYPTION_KEY = new Uint8Array(32).fill(7); diff --git a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts index aef922cbe63..04c6a10cfc4 100644 --- a/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapEncryptionKey.ts @@ -1,6 +1,6 @@ import { randomBytes, box } from 'tweetnacl'; -import { base64UrlToBytes, toBase64Url } from './encoding.js'; +import { base64UrlToBytes, toBase64Url } from '../encoding.js'; /** * Wraps the `data_encryption_key` for the UKYC session server using NaCl's diff --git a/packages/kyc-controller/src/ukyc/wrapUserKey.ts b/packages/kyc-controller/src/ukyc/wrapUserKey.ts index cd139d51676..f694d6310e8 100644 --- a/packages/kyc-controller/src/ukyc/wrapUserKey.ts +++ b/packages/kyc-controller/src/ukyc/wrapUserKey.ts @@ -5,7 +5,7 @@ import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; import { hexToBytes, randomBytes } from '@noble/hashes/utils'; -import { toBase64Url } from './encoding.js'; +import { toBase64Url } from '../encoding.js'; /** * Wraps (encrypts) a symmetric key so that only the holder of a given X25519 diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts index d5f6e136e2d..ae28ec4ec2d 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -1,5 +1,5 @@ import type { UkycClientMaterial } from './deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; +import { toBase64Url } from '../encoding.js'; import type { UkycStorageAccessToken } from './storageAccessToken.js'; /** From 6b7da8487d769301a6b7ad5a390b4dcfca82f818 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 7 Aug 2026 13:57:25 +0200 Subject: [PATCH 44/67] fix: yarn constraints --- packages/kyc-controller/package.json | 2 +- yarn.lock | 28 ++-------------------------- 2 files changed, 3 insertions(+), 27 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 811bf1e9725..bada057388b 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -61,7 +61,7 @@ "@metamask/controller-utils": "^12.3.0", "@metamask/geolocation-controller": "^1.0.0", "@metamask/messenger": "^2.0.0", - "@metamask/profile-sync-controller": "^28.3.0", + "@metamask/profile-sync-controller": "^29.0.0", "@metamask/superstruct": "^3.1.0", "@metamask/utils": "^11.11.0", "@noble/ciphers": "^1.3.0", diff --git a/yarn.lock b/yarn.lock index f914716b641..72300ceff2f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7548,7 +7548,7 @@ __metadata: languageName: node linkType: hard -"@metamask/keyring-controller@npm:^27.1.0, @metamask/keyring-controller@npm:^27.1.1, @metamask/keyring-controller@workspace:packages/keyring-controller": +"@metamask/keyring-controller@npm:^27.1.1, @metamask/keyring-controller@workspace:packages/keyring-controller": version: 0.0.0-use.local resolution: "@metamask/keyring-controller@workspace:packages/keyring-controller" dependencies: @@ -7686,7 +7686,7 @@ __metadata: "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" "@metamask/messenger": "npm:^2.0.0" - "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.1.0" "@metamask/utils": "npm:^11.11.0" "@noble/ciphers": "npm:^1.3.0" @@ -8613,30 +8613,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^28.3.0": - version: 28.3.0 - resolution: "@metamask/profile-sync-controller@npm:28.3.0" - dependencies: - "@metamask/address-book-controller": "npm:^7.1.2" - "@metamask/base-controller": "npm:^9.1.0" - "@metamask/keyring-controller": "npm:^27.1.0" - "@metamask/messenger": "npm:^2.0.0" - "@metamask/snaps-controllers": "npm:^19.0.0" - "@metamask/snaps-sdk": "npm:^11.0.0" - "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/utils": "npm:^11.11.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/hashes": "npm:^1.8.0" - immer: "npm:^9.0.6" - loglevel: "npm:^1.8.1" - siwe: "npm:^2.3.2" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/a0fa6dad8a183bcd41907e7ab5d35fcb321bd1c7040125120deebc20b3d809a0e0f076feb2e80ef70b9ce1330138a8d1c8d8b5eea18086cd765ae4301a32e2ba - languageName: node - linkType: hard - "@metamask/profile-sync-controller@npm:^29.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" From 801e09874bb2846655577aa4e5d56bc80bb8a3ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Fri, 7 Aug 2026 16:08:16 +0200 Subject: [PATCH 45/67] fix: bump dependency for constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- packages/kyc-controller/package.json | 2 +- yarn.lock | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index bada057388b..07f34b7cec8 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -62,7 +62,7 @@ "@metamask/geolocation-controller": "^1.0.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", - "@metamask/superstruct": "^3.1.0", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^11.11.0", "@noble/ciphers": "^1.3.0", "@noble/curves": "^1.9.2", diff --git a/yarn.lock b/yarn.lock index 72300ceff2f..35c6bbed9c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7687,7 +7687,7 @@ __metadata: "@metamask/geolocation-controller": "npm:^1.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" - "@metamask/superstruct": "npm:^3.1.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" "@noble/ciphers": "npm:^1.3.0" "@noble/curves": "npm:^1.9.2" From ece1fef0b6d79ecc6164621885d0c0bd8a276489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Sat, 8 Aug 2026 01:43:24 +0200 Subject: [PATCH 46/67] fix: missing entry in readme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Sébastien Van Eyck --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9380b44d860..8dd71a5523e 100644 --- a/README.md +++ b/README.md @@ -426,6 +426,7 @@ linkStyle default opacity:0.5 keyring_controller --> controller_utils; keyring_controller --> messenger; kyc_controller --> base_controller; + kyc_controller --> base_data_service; kyc_controller --> controller_utils; kyc_controller --> geolocation_controller; kyc_controller --> messenger; From 81246f6c9790b4beb494668a3eded8257726e384 Mon Sep 17 00:00:00 2001 From: Bjorn Date: Wed, 12 Aug 2026 10:19:29 +0000 Subject: [PATCH 47/67] Adjusting the storage token to the spec --- packages/kyc-controller/src/ukyc/constants.ts | 20 ++++++++++++++-- .../src/ukyc/storageAccessToken.test.ts | 19 ++++++++++++--- .../src/ukyc/storageAccessToken.ts | 24 +++++++++++++++---- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts index d50850dcf65..a3fb0187555 100644 --- a/packages/kyc-controller/src/ukyc/constants.ts +++ b/packages/kyc-controller/src/ukyc/constants.ts @@ -46,12 +46,28 @@ export const UKYC_KDF_INFO = { export const UKYC_STORAGE_ACCESS_TOKEN_VERSION = 1; /** - * Audience bound into every `storage_access_token` payload, scoping the - * capability to the UKYC user-storage service. + * Audience identifying the UKYC user-storage service. Required by UKYC Storage + * when it verifies a `storage_access_token`. */ export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE = 'metamask:user-storage:ukyc' as const; +/** + * Audience identifying the idOS Kwil credential-registry nodes. Required by + * idOS Kwil when it verifies a `storage_access_token`. + */ +export const UKYC_KWIL_AUDIENCE = 'idos:kwil' as const; + +/** + * Full audience list bound into every `storage_access_token` payload. `aud` + * lists every verifier that may accept the token, so both UKYC Storage and + * idOS Kwil can each find their own entry. + */ +export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES = [ + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +] as const; + /** * Standard well-known path where the Fractal encryption service publishes its * JWKS (the Ed25519 public keys used to sign the `jwtChain`). diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts index 905d549490d..d0299e7c9ef 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.test.ts @@ -2,6 +2,7 @@ import { base64ToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; import { + UKYC_KWIL_AUDIENCE, UKYC_LOCAL_USER_SECRET_SIZE_BYTES, UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_STORAGE_ACCESS_TOKEN_VERSION, @@ -73,17 +74,29 @@ describe('UKYC signStorageAccessToken', () => { expect(token.payload).toStrictEqual({ version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, - aud: UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], storage_id: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), signing_public_key: expect.stringMatching(/^[A-Za-z0-9_-]+$/u), operations: ['delete'], presenter: 'client', - issued_at: '2026-07-07T00:00:00.000Z', - expires_at: '2026-07-07T04:00:00.000Z', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', }); expect(token.payload).not.toHaveProperty('session_id'); }); + it('formats timestamps as RFC 3339 with whole seconds, truncating sub-second precision', () => { + const token = signStorageAccessToken({ + material: MATERIAL, + operations: ['read'], + issuedAt: new Date('2026-07-07T00:00:00.715Z'), + expiresAt: new Date('2026-07-07T04:00:00.999Z'), + }); + + expect(token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + it('produces a signature that verifies against the signing public key', () => { const token = signStorageAccessToken({ material: MATERIAL, diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index ac2af14b16d..e9dd12a0130 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -2,7 +2,7 @@ import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; import { - UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, UKYC_STORAGE_ACCESS_TOKEN_VERSION, } from './constants.js'; import type { UkycClientMaterial } from './deriveClientMaterial.js'; @@ -36,7 +36,8 @@ export type UkycTokenPresenter = 'client' | 'idos-relay'; */ export type UkycStorageAccessTokenPayload = { version: number; - aud: string; + /** Every verifier that may accept the token, e.g. UKYC Storage and idOS Kwil. */ + aud: string[]; // Wire-shape fields are snake_case; they are canonicalized and signed exactly // as they appear on the wire. /* eslint-disable @typescript-eslint/naming-convention */ @@ -139,6 +140,19 @@ export function canonicalizeJson(value: JsonValue): string { return `{${entries.join(',')}}`; } +/** + * Formats a date as RFC 3339 UTC with whole-second precision (e.g. + * `2026-07-07T00:00:00Z`). `Date.prototype.toISOString` always emits + * milliseconds (`...:00.000Z`); the `storage_access_token` wire format omits + * fractional seconds, so the sub-second component is truncated (not rounded). + * + * @param date - The date to format. + * @returns The RFC 3339 timestamp without fractional seconds. + */ +function toRfc3339Seconds(date: Date): string { + return `${date.toISOString().slice(0, 19)}Z`; +} + /** * Builds and signs a `storage_access_token`. * @@ -181,13 +195,13 @@ export function signStorageAccessToken( const payload: UkycStorageAccessTokenPayload = { version: UKYC_STORAGE_ACCESS_TOKEN_VERSION, - aud: UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + aud: [...UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES], storage_id: toBase64Url(material.storageId), signing_public_key: toBase64Url(material.signingPublicKey), operations, presenter, - issued_at: issuedAt.toISOString(), - expires_at: expiresAt.toISOString(), + issued_at: toRfc3339Seconds(issuedAt), + expires_at: toRfc3339Seconds(expiresAt), }; // Only bind session_id for Relay-presented tokens; omit the key entirely for From e57e5c3dc4a85c557206427e3b55319aa8698a5a Mon Sep 17 00:00:00 2001 From: Bjorn Date: Wed, 12 Aug 2026 14:36:41 +0000 Subject: [PATCH 48/67] Creating a test token script --- eslint.config.mjs | 9 ++ packages/kyc-controller/package.json | 1 + .../scripts/mint-ukyc-test-token.ts | 106 ++++++++++++++ packages/kyc-controller/src/index.ts | 34 +++++ packages/kyc-controller/src/ukyc/constants.ts | 7 + .../src/ukyc/storageAccessToken.ts | 5 +- .../kyc-controller/src/ukyc/testToken.test.ts | 133 ++++++++++++++++++ packages/kyc-controller/src/ukyc/testToken.ts | 132 +++++++++++++++++ packages/kyc-controller/tsconfig.json | 2 +- 9 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 packages/kyc-controller/scripts/mint-ukyc-test-token.ts create mode 100644 packages/kyc-controller/src/ukyc/testToken.test.ts create mode 100644 packages/kyc-controller/src/ukyc/testToken.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index faf5e8039aa..4275fa3d871 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -314,6 +314,15 @@ const config = createConfig([ 'no-restricted-globals': 'off', }, }, + { + // The UKYC test-token minter is a dev-only Node CLI, so it may use Node + // builtins and globals unlike the platform-agnostic package source. + files: ['packages/kyc-controller/scripts/**/*.ts'], + rules: { + 'import-x/no-nodejs-modules': 'off', + 'no-restricted-globals': 'off', + }, + }, { files: [ 'packages/wallet-cli/src/**/*.test.{js,ts}', diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 07f34b7cec8..5b0c23f7fff 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -49,6 +49,7 @@ "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", + "mint:ukyc-token": "tsx scripts/mint-ukyc-test-token.ts", "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", diff --git a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts new file mode 100644 index 00000000000..82b51801acd --- /dev/null +++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts @@ -0,0 +1,106 @@ +/** + * CLI to mint a UKYC `storage_access_token` for testing UKYC Storage. + * + * All real logic lives in the tested `mintUkycTestToken`; this is a thin + * argument-parsing wrapper that prints the result as JSON. + * + * Usage (from the package root, via the `mint:ukyc-token` script): + * yarn workspace @metamask/kyc-controller run mint:ukyc-token -- \ + * --operations read,write --expires-in 4h [--secret ] \ + * [--presenter client|idos-relay] [--session-id ] + * + * Reuse the printed `localUserSecret` (pass it back via --secret) to keep the + * same `storageId` and controlling key across runs. + */ +import process from 'node:process'; + +import type { + UkycStorageOperation, + UkycTokenPresenter, +} from '../src/ukyc/storageAccessToken.js'; +import { mintUkycTestToken } from '../src/ukyc/testToken.js'; +import type { MintUkycTestTokenParams } from '../src/ukyc/testToken.js'; + +/** + * Parses `--flag value` and `--flag=value` pairs into a map. Flags without a + * following value are treated as booleans (`"true"`). + * + * @param argv - Raw CLI arguments (typically `process.argv.slice(2)`). + * @returns The parsed flags keyed by name (without the leading `--`). + */ +function parseFlags(argv: string[]): Record { + const flags: Record = {}; + let i = 0; + while (i < argv.length) { + const arg = argv[i]; + if (!arg.startsWith('--')) { + i += 1; + continue; + } + const body = arg.slice(2); + const eq = body.indexOf('='); + if (eq !== -1) { + flags[body.slice(0, eq)] = body.slice(eq + 1); + i += 1; + continue; + } + const next = argv[i + 1]; + if (next !== undefined && !next.startsWith('--')) { + flags[body] = next; + i += 2; + } else { + flags[body] = 'true'; + i += 1; + } + } + return flags; +} + +/** + * Parses a duration like `4h`, `30m`, `90s`, or a bare number of seconds. + * + * @param value - The duration string. + * @returns The duration in milliseconds. + */ +function parseDurationMs(value: string): number { + const match = /^(\d+)(s|m|h|d)?$/u.exec(value); + if (!match) { + throw new Error(`invalid --expires-in duration: ${value}`); + } + const amount = Number(match[1]); + const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }; + return amount * unitMs[(match[2] ?? 's') as keyof typeof unitMs]; +} + +const flags = parseFlags(process.argv.slice(2)); + +const params: MintUkycTestTokenParams = {}; + +if (flags.secret) { + params.localUserSecret = flags.secret; +} +if (flags.operations) { + params.operations = flags.operations + .split(',') + .map((op) => op.trim()) as UkycStorageOperation[]; +} +if (flags.presenter) { + params.presenter = flags.presenter as UkycTokenPresenter; +} +if (flags['session-id']) { + params.sessionId = flags['session-id']; +} +if (flags['issued-at']) { + params.issuedAt = new Date(flags['issued-at']); +} +if (flags['expires-at']) { + params.expiresAt = new Date(flags['expires-at']); +} else if (flags['expires-in']) { + const issuedAt = params.issuedAt ?? new Date(); + params.issuedAt = issuedAt; + params.expiresAt = new Date(issuedAt.getTime() + parseDurationMs(flags['expires-in'])); +} + +const result = mintUkycTestToken(params); + +console.log(JSON.stringify(result, null, 2)); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index d20f17916e9..20830da2c55 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -85,3 +85,37 @@ export type { KycSumSubStatus, KycVendor, } from './types.js'; + +// UKYC storage-access-token utilities. Exported so a signed capability token can +// be minted for testing UKYC Storage (see `mintUkycTestToken`). +export { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_KWIL_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, + UKYC_STORAGE_ACCESS_TOKEN_VERSION, +} from './ukyc/constants.js'; +export { + deriveClientMaterial, + encodeClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export type { + EncodedUkycClientMaterial, + UkycClientMaterial, +} from './ukyc/deriveClientMaterial.js'; +export { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './ukyc/storageAccessToken.js'; +export type { + SignStorageAccessTokenParams, + UkycStorageAccessToken, + UkycStorageAccessTokenPayload, + UkycStorageOperation, + UkycTokenPresenter, +} from './ukyc/storageAccessToken.js'; +export { mintUkycTestToken } from './ukyc/testToken.js'; +export type { + MintedUkycTestToken, + MintUkycTestTokenParams, +} from './ukyc/testToken.js'; diff --git a/packages/kyc-controller/src/ukyc/constants.ts b/packages/kyc-controller/src/ukyc/constants.ts index a3fb0187555..c75c850c521 100644 --- a/packages/kyc-controller/src/ukyc/constants.ts +++ b/packages/kyc-controller/src/ukyc/constants.ts @@ -68,6 +68,13 @@ export const UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES = [ UKYC_KWIL_AUDIENCE, ] as const; +/** + * Authorization scheme under which a signed `storage_access_token` envelope is + * carried to UKYC Storage: `Authorization: AccessToken `. + * The credentials portion is what `encodeStorageAccessTokenForHeader` returns. + */ +export const UKYC_CAPABILITY_AUTH_SCHEME = 'AccessToken' as const; + /** * Standard well-known path where the Fractal encryption service publishes its * JWKS (the Ed25519 public keys used to sign the `jwtChain`). diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index e9dd12a0130..abc9455a1e3 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -221,8 +221,9 @@ export function signStorageAccessToken( /** * Serializes a signed token to a compact string suitable for header transport - * (e.g. `Authorization: StorageAccessToken `). The complete envelope is - * base64url-encoded; the private `signing_key` is never included. + * (`Authorization: AccessToken `, see `UKYC_CAPABILITY_AUTH_SCHEME`). The + * complete envelope is base64url-encoded; the private `signing_key` is never + * included. * * @param token - The signed token envelope. * @returns The base64url-encoded envelope string. diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts new file mode 100644 index 00000000000..2784d87ba28 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -0,0 +1,133 @@ +import { ed25519 } from '@noble/curves/ed25519'; +import { hexToBytes, stringToBytes } from '@metamask/utils'; + +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, + UKYC_KWIL_AUDIENCE, +} from './constants.js'; +import { canonicalizeJson } from './storageAccessToken.js'; +import { mintUkycTestToken } from './testToken.js'; +import { base64UrlToBytes } from '../encoding.js'; + +// A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable. +const SECRET_HEX = '42'.repeat(32); +const ISSUED_AT = new Date('2026-07-07T00:00:00Z'); +const EXPIRES_AT = new Date('2026-07-07T04:00:00Z'); + +/** + * Splits an `AccessToken ` header and decodes the credentials into the + * envelope, the way UKYC Storage does on the wire. + * + * @param header - The full Authorization header value. + * @returns The decoded token envelope. + */ +function decodeHeader(header: string): { + payload: Record; + signature: string; +} { + const [scheme, creds] = header.split(' '); + expect(scheme).toBe(UKYC_CAPABILITY_AUTH_SCHEME); + return JSON.parse(new TextDecoder().decode(base64UrlToBytes(creds))); +} + +describe('UKYC mintUkycTestToken', () => { + it('mints a client token from a hex secret with the derived identifiers', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.localUserSecret).toBe(SECRET_HEX); + expect(result.token.payload).toMatchObject({ + version: 1, + aud: [UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, UKYC_KWIL_AUDIENCE], + operations: ['read', 'write'], + presenter: 'client', + issued_at: '2026-07-07T00:00:00Z', + expires_at: '2026-07-07T04:00:00Z', + }); + // storage_id / signing_public_key are the client-derived values, not + // anything a server fills in. + expect(result.storageId).toBe(result.token.payload.storage_id); + expect(result.signingPublicKey).toBe( + result.token.payload.signing_public_key, + ); + expect(result.token.payload).not.toHaveProperty('session_id'); + }); + + it('produces an Authorization header whose signature verifies (as UKYC Storage checks it)', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + const envelope = decodeHeader(result.authorizationHeader); + const message = stringToBytes(canonicalizeJson(envelope.payload)); + const signature = base64UrlToBytes(envelope.signature); + const publicKey = base64UrlToBytes(result.signingPublicKey); + + expect(ed25519.verify(signature, message, publicKey)).toBe(true); + }); + + it('defaults operations to ["read"] and expiry to issued_at + 4h', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + issuedAt: ISSUED_AT, + }); + + expect(result.token.payload.operations).toStrictEqual(['read']); + expect(result.token.payload.issued_at).toBe('2026-07-07T00:00:00Z'); + expect(result.token.payload.expires_at).toBe('2026-07-07T04:00:00Z'); + }); + + it('accepts a raw byte secret and is deterministic for the same inputs', () => { + const secret = hexToBytes(SECRET_HEX); + const params = { + localUserSecret: secret, + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }; + + expect(mintUkycTestToken(params)).toStrictEqual(mintUkycTestToken(params)); + }); + + it('generates a fresh random secret when none is supplied', () => { + const result = mintUkycTestToken(); + + // 32 bytes hex-encoded. + expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u); + expect(result.token.payload.operations).toStrictEqual(['read']); + expect(result.authorizationHeader.startsWith( + `${UKYC_CAPABILITY_AUTH_SCHEME} `, + )).toBe(true); + }); + + it('binds session_id for a Relay-presented token', () => { + const result = mintUkycTestToken({ + localUserSecret: SECRET_HEX, + operations: ['read', 'write'], + presenter: 'idos-relay', + sessionId: 'session-1', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }); + + expect(result.token.payload.presenter).toBe('idos-relay'); + expect(result.token.payload.session_id).toBe('session-1'); + }); + + it('rejects a Relay presenter without a session_id', () => { + expect(() => + mintUkycTestToken({ + localUserSecret: SECRET_HEX, + presenter: 'idos-relay', + issuedAt: ISSUED_AT, + expiresAt: EXPIRES_AT, + }), + ).toThrow('requires a session_id'); + }); +}); diff --git a/packages/kyc-controller/src/ukyc/testToken.ts b/packages/kyc-controller/src/ukyc/testToken.ts new file mode 100644 index 00000000000..055d1b08448 --- /dev/null +++ b/packages/kyc-controller/src/ukyc/testToken.ts @@ -0,0 +1,132 @@ +import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'; + +import { + UKYC_CAPABILITY_AUTH_SCHEME, + UKYC_LOCAL_USER_SECRET_SIZE_BYTES, +} from './constants.js'; +import { + deriveClientMaterial, + encodeClientMaterial, +} from './deriveClientMaterial.js'; +import { + encodeStorageAccessTokenForHeader, + signStorageAccessToken, +} from './storageAccessToken.js'; +import type { + UkycStorageAccessToken, + UkycStorageOperation, + UkycTokenPresenter, +} from './storageAccessToken.js'; + +/** + * Mints ready-to-use UKYC `storage_access_token`s for testing UKYC Storage. + * + * This composes the same pure functions the MetaMask client uses in production + * (`deriveClientMaterial` + `signStorageAccessToken`), so a third party such as + * idOS can produce a valid, signed token without deriving one on-device. No + * server "fills in" the `signing_public_key`: it is derived from a + * `local_user_secret` the caller controls and registered on first write, so + * reusing the same secret yields a stable `storage_id` and controlling key. + */ + +/** Default token lifetime when `expiresAt` is not supplied (4 hours). */ +const DEFAULT_TOKEN_LIFETIME_MS = 4 * 60 * 60 * 1000; + +/** + * Inputs for {@link mintUkycTestToken}. All fields are optional; the only value + * a caller usually pins is `localUserSecret`, so `storage_id` and the signing + * key stay stable across runs. + */ +export type MintUkycTestTokenParams = { + /** + * The root `local_user_secret`, as raw 32 bytes or a hex string. When omitted + * a fresh random secret is generated (returned in the result so it can be + * reused). + */ + localUserSecret?: Uint8Array | string; + /** Operations the token authorizes. Defaults to `['read']`. */ + operations?: UkycStorageOperation[]; + /** Who will present the token. Defaults to `client`. */ + presenter?: UkycTokenPresenter; + /** UKYC session id. Required when `presenter` is `idos-relay`. */ + sessionId?: string; + /** Token issue time. Defaults to now. */ + issuedAt?: Date; + /** Token expiry. Defaults to `issuedAt` + 4 hours. */ + expiresAt?: Date; +}; + +/** + * The minted token plus everything needed to exercise UKYC Storage with it. + */ +export type MintedUkycTestToken = { + /** The `local_user_secret` used, hex-encoded, so the caller can reuse it. */ + localUserSecret: string; + /** base64url `storage_id` — use it as the `{storage_id}` path segment. */ + storageId: string; + /** base64url `signing_public_key` registered on first write. */ + signingPublicKey: string; + /** The signed token envelope (payload + signature). */ + token: UkycStorageAccessToken; + /** + * The full `Authorization` header value, e.g. + * `AccessToken `, ready to send to UKYC Storage. + */ + authorizationHeader: string; +}; + +/** + * Resolves the caller-supplied secret into raw bytes, generating a random one + * when none is provided. + * + * @param secret - Raw 32 bytes, a hex string, or undefined for a random secret. + * @returns The `local_user_secret` bytes. + */ +function resolveLocalUserSecret(secret?: Uint8Array | string): Uint8Array { + if (secret === undefined) { + return randomBytes(UKYC_LOCAL_USER_SECRET_SIZE_BYTES); + } + return typeof secret === 'string' ? hexToBytes(secret) : secret; +} + +/** + * Mints a signed UKYC `storage_access_token` for testing. + * + * @param params - See {@link MintUkycTestTokenParams}. + * @returns The token, its `Authorization` header, and the derived identifiers. + */ +export function mintUkycTestToken( + params: MintUkycTestTokenParams = {}, +): MintedUkycTestToken { + const { + operations = ['read'], + presenter, + sessionId, + issuedAt = new Date(), + expiresAt = new Date(issuedAt.getTime() + DEFAULT_TOKEN_LIFETIME_MS), + } = params; + + const localUserSecret = resolveLocalUserSecret(params.localUserSecret); + const material = deriveClientMaterial(localUserSecret); + + const token = signStorageAccessToken({ + material, + operations, + presenter, + sessionId, + issuedAt, + expiresAt, + }); + + const { storageId, signingPublicKey } = encodeClientMaterial(material); + + return { + localUserSecret: bytesToHex(localUserSecret), + storageId, + signingPublicKey, + token, + authorizationHeader: `${UKYC_CAPABILITY_AUTH_SCHEME} ${encodeStorageAccessTokenForHeader( + token, + )}`, + }; +} diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json index 8fd2d917930..1079229158f 100644 --- a/packages/kyc-controller/tsconfig.json +++ b/packages/kyc-controller/tsconfig.json @@ -11,5 +11,5 @@ { "path": "../messenger" }, { "path": "../profile-sync-controller" } ], - "include": ["../../types", "./src"] + "include": ["../../types", "./src", "./scripts"] } From 176da6a5aa2d5d7c05d1afcfb0ce949a7449de7d Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 12 Aug 2026 10:56:50 -0600 Subject: [PATCH 49/67] feat(ramps): add autoramp accounts, NeoBankService, and User Storage sync Introduce AutorampAccount state in RampsController with refresh/compare helpers, a NeoBankService Ramp API proxy client, and rampsAutoramps Profile Sync for cross-device last-seen status. Co-authored-by: Cursor --- .../src/shared/storage-schema.ts | 1 + .../src/NeoBankService-method-action-types.ts | 23 + .../src/NeoBankService.test.ts | 80 ++++ .../ramps-controller/src/NeoBankService.ts | 246 ++++++++++ .../RampsController-method-action-types.ts | 86 ++++ .../src/RampsController.test.ts | 202 ++++++++- .../ramps-controller/src/RampsController.ts | 367 ++++++++++++++- .../src/autoramp-syncing/constants.ts | 25 + .../controller-integration.ts | 429 ++++++++++++++++++ .../src/autoramp-syncing/format-utils.test.ts | 76 ++++ .../src/autoramp-syncing/format-utils.ts | 127 ++++++ .../src/autoramp-syncing/index.ts | 28 ++ .../src/autoramp-syncing/sync-utils.test.ts | 128 ++++++ .../src/autoramp-syncing/sync-utils.ts | 48 ++ .../src/autoramp-syncing/types.ts | 70 +++ .../src/autorampAccount.test.ts | 163 +++++++ .../ramps-controller/src/autorampAccount.ts | 221 +++++++++ packages/ramps-controller/src/index.ts | 53 +++ 18 files changed, 2369 insertions(+), 4 deletions(-) create mode 100644 packages/ramps-controller/src/NeoBankService-method-action-types.ts create mode 100644 packages/ramps-controller/src/NeoBankService.test.ts create mode 100644 packages/ramps-controller/src/NeoBankService.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/constants.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/controller-integration.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/format-utils.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/index.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/sync-utils.ts create mode 100644 packages/ramps-controller/src/autoramp-syncing/types.ts create mode 100644 packages/ramps-controller/src/autorampAccount.test.ts create mode 100644 packages/ramps-controller/src/autorampAccount.ts diff --git a/packages/profile-sync-controller/src/shared/storage-schema.ts b/packages/profile-sync-controller/src/shared/storage-schema.ts index dc9f73fcfb6..e8e74f363e2 100644 --- a/packages/profile-sync-controller/src/shared/storage-schema.ts +++ b/packages/profile-sync-controller/src/shared/storage-schema.ts @@ -13,6 +13,7 @@ export const USER_STORAGE_FEATURE_NAMES = { notifications: 'notifications', accounts: 'accounts_v2', addressBook: 'addressBook', + rampsAutoramps: 'rampsAutoramps', }; export type UserStorageGenericFeatureName = string; diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts new file mode 100644 index 00000000000..48dcc1839b7 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -0,0 +1,23 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { NeoBankService } from './NeoBankService.js'; + +/** + * Fetches an autoramp account via the Ramp API proxy of + * MoonPay `GET /api/autoramps/{autoramp_id}`. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceGetAutorampAction = { + type: `NeoBankService:getAutoramp`; + handler: NeoBankService['getAutoramp']; +}; + +/** + * Union of all NeoBankService action types. + */ +export type NeoBankServiceMethodActions = NeoBankServiceGetAutorampAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts new file mode 100644 index 00000000000..a0765b00024 --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -0,0 +1,80 @@ +import nock from 'nock'; + +import { + mapNeoBankAutorampToRemoteSnapshot, + NeoBankService, +} from './NeoBankService.js'; +import type { NeoBankServiceMessenger } from './NeoBankService.js'; +import { RampsEnvironment } from './RampsService.js'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; + +describe('NeoBankService', () => { + describe('mapNeoBankAutorampToRemoteSnapshot', () => { + it('maps MoonPay-shaped fields into a remote snapshot', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + wallet_address: '0xabc', + deposit_rails: [{ type: 'Iban' }], + }), + ).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: 'Approved', + depositRailsSummary: { ready: true }, + }); + }); + }); + + describe('getAutoramp', () => { + it('GETs the proxied autoramp endpoint with bearer auth', async () => { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'test-token', + ); + + const messenger = new Messenger({ + namespace: 'NeoBankService', + parent: rootMessenger, + }) as unknown as NeoBankServiceMessenger; + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + }); + + const scope = nock('https://on-ramp.uat-api.cx.metamask.io') + .get(/\/api\/v2\/autoramps\/ar-1/u) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + wallet_address: '0xabc', + }); + + const service = new NeoBankService({ + messenger, + environment: RampsEnvironment.Staging, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + }); + + const snapshot = await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-1', + customerId: 'cust-1', + status: 'Authorized', + walletAddress: '0xabc', + }); + }); + }); +}); diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts new file mode 100644 index 00000000000..ca8f5dd7b0a --- /dev/null +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -0,0 +1,246 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; + +import packageJson from '../package.json'; +import type { + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; +import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; + +/** + * Name of the NeoBankService messenger namespace. + */ +export const serviceName = 'NeoBankService'; + +/** + * Raw autoramp payload from the MetaMask Ramp API neo-bank proxy. + * Shape mirrors MoonPay Enterprise `GET /api/autoramps/{autoramp_id}`. + * The Ramp API handles partner auth / headers; the client only sends the + * MetaMask bearer token. + */ +export type NeoBankAutorampResponse = { + id: string; + customer_id: string; + status: string; + /** + * Destination wallet when present on the proxy response. + * Field name may evolve with the Ramp API contract. + */ + wallet_address?: string; + recipient_account?: { + address?: string; + }; + deposit_rails?: unknown[]; +}; + +const MESSENGER_EXPOSED_METHODS = ['getAutoramp'] as const; + +/** + * Actions that {@link NeoBankService} exposes to other consumers. + */ +export type NeoBankServiceActions = NeoBankServiceMethodActions; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +export type NeoBankServiceEvents = never; + +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link NeoBankService}. + */ +export type NeoBankServiceMessenger = Messenger< + typeof serviceName, + NeoBankServiceActions | AllowedActions, + NeoBankServiceEvents | AllowedEvents +>; + +/** + * Builds an `/api/v2/...` path for the Ramp API neo-bank proxy. + * + * @param path - Path under the versioned API root (no leading slash). + * @param version - API version segment. + * @returns Versioned API path. + */ +function getApiPath(path: string, version: string = 'v2'): string { + return `api/${version}/${path.replace(/^\//u, '')}`; +} + +/** + * Resolves the Ramp API host for neo-bank calls (same hosts as {@link RampsService}). + * + * @param environment - Ramp environment. + * @returns Base URL. + */ +function getBaseUrl(environment: RampsEnvironment): string { + switch (environment) { + case RampsEnvironment.Production: + return 'https://on-ramp.api.cx.metamask.io'; + case RampsEnvironment.Staging: + return 'https://on-ramp.uat-api.cx.metamask.io'; + case RampsEnvironment.Development: + return 'https://on-ramp.dev-api.cx.metamask.io'; + case RampsEnvironment.Local: + return 'http://localhost:3000'; + default: + throw new Error(`Invalid environment: ${String(environment)}`); + } +} + +/** + * Maps a Ramp API / MoonPay-shaped autoramp response into the local remote snapshot. + * + * @param response - Proxy response body. + * @returns Snapshot consumed by {@link applyAutorampRemoteStatus}. + */ +export function mapNeoBankAutorampToRemoteSnapshot( + response: NeoBankAutorampResponse, +): AutorampRemoteSnapshot { + const depositRails = response.deposit_rails; + const hasDepositRails = Array.isArray(depositRails) && depositRails.length > 0; + const depositRailsSummary: AutorampDepositRailsSummary | undefined = + hasDepositRails || response.status === 'Approved' + ? { + ready: response.status === 'Approved' && hasDepositRails, + } + : undefined; + + return { + id: response.id, + customerId: response.customer_id, + walletAddress: + response.wallet_address ?? response.recipient_account?.address, + status: response.status, + depositRailsSummary, + }; +} + +/** + * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). + * + * Lives alongside {@link RampsService} and {@link TransakService}. Authentication + * and MoonPay partner headers are handled by the Ramp API — this service only + * attaches the MetaMask user bearer token. + */ +export class NeoBankService { + readonly name: typeof serviceName; + + readonly #messenger: NeoBankServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #policy: ServicePolicy; + + readonly #environment: RampsEnvironment; + + readonly #context: string; + + readonly #baseUrlOverride?: string; + + constructor({ + messenger, + environment = RampsEnvironment.Staging, + context, + fetch: fetchFunction, + policyOptions = {}, + baseUrlOverride, + }: { + messenger: NeoBankServiceMessenger; + environment?: RampsEnvironment; + context: string; + fetch: typeof fetch; + policyOptions?: CreateServicePolicyOptions; + baseUrlOverride?: string; + }) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#policy = createServicePolicy(policyOptions); + this.#environment = environment; + this.#context = context; + this.#baseUrlOverride = baseUrlOverride; + + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #getBaseUrl(): string { + if (this.#baseUrlOverride) { + return this.#baseUrlOverride; + } + return getBaseUrl(this.#environment); + } + + async #getRequestHeaders(): Promise> { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + return { + Authorization: `Bearer ${bearerToken}`, + }; + } + + /** + * Fetches an autoramp account via the Ramp API proxy of + * MoonPay `GET /api/autoramps/{autoramp_id}`. + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ + async getAutoramp(autorampId: string): Promise { + const url = new URL( + getApiPath(`autoramps/${encodeURIComponent(autorampId)}`), + this.#getBaseUrl(), + ); + url.searchParams.set('sdk', RAMPS_SDK_VERSION); + url.searchParams.set('controller', packageJson.version); + url.searchParams.set('context', this.#context); + + const response = await this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(); + const fetchResponse = await this.#fetch(url, { headers }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; + }); + + if (!response || typeof response !== 'object' || !response.id) { + throw new Error('Malformed response received from neo-bank autoramp API'); + } + + return mapNeoBankAutorampToRemoteSnapshot(response); + } + + onRetry( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onRetry(listener); + } + + onBreak( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onBreak(listener); + } + + onDegraded( + listener: Parameters[0], + ): ReturnType { + return this.#policy.onDegraded(listener); + } +} diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 3898e4fea14..4fa4d1f19e7 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -280,6 +280,85 @@ export type RampsControllerRemoveOrderAction = { handler: RampsController['removeOrder']; }; +/** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ +export type RampsControllerAddAutorampAction = { + type: `RampsController:addAutoramp`; + handler: RampsController['addAutoramp']; +}; + +/** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerRemoveAutorampAction = { + type: `RampsController:removeAutoramp`; + handler: RampsController['removeAutoramp']; +}; + +/** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ +export type RampsControllerMarkAutorampAsNotifiedAction = { + type: `RampsController:markAutorampAsNotified`; + handler: RampsController['markAutorampAsNotified']; +}; + +/** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ +export type RampsControllerApplyAutorampStatusFromPushAction = { + type: `RampsController:applyAutorampStatusFromPush`; + handler: RampsController['applyAutorampStatusFromPush']; +}; + +/** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ +export type RampsControllerRefreshAutorampAction = { + type: `RampsController:refreshAutoramp`; + handler: RampsController['refreshAutoramp']; +}; + +/** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ +export type RampsControllerRefreshAutorampsAction = { + type: `RampsController:refreshAutoramps`; + handler: RampsController['refreshAutoramps']; +}; + +/** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ +export type RampsControllerSyncAutorampsWithUserStorageAction = { + type: `RampsController:syncAutorampsWithUserStorage`; + handler: RampsController['syncAutorampsWithUserStorage']; +}; + /** * Starts polling all pending V2 orders at a fixed interval. * Each poll cycle iterates orders with non-terminal statuses, @@ -689,6 +768,13 @@ export type RampsControllerMethodActions = | RampsControllerGetQuotesAction | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction + | RampsControllerAddAutorampAction + | RampsControllerRemoveAutorampAction + | RampsControllerMarkAutorampAsNotifiedAction + | RampsControllerApplyAutorampStatusFromPushAction + | RampsControllerRefreshAutorampAction + | RampsControllerRefreshAutorampsAction + | RampsControllerSyncAutorampsWithUserStorageAction | RampsControllerStartOrderPollingAction | RampsControllerStopOrderPollingAction | RampsControllerGetBuyWidgetDataAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 54dca251d3c..e71ecf6b9df 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -12,6 +12,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; +import { AutorampStatus } from './autorampAccount.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -22,6 +23,7 @@ import { RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; import type { @@ -77,12 +79,12 @@ describe('RampsController', () => { 'Execution prevented because the circuit breaker is open'; describe('RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS', () => { - it('includes every RampsService action that RampsController calls', async () => { + it('includes every RampsService, TransakService, and NeoBankService action that RampsController calls', async () => { expect.hasAssertions(); const controllerPath = path.join(__dirname, 'RampsController.ts'); const source = await fs.promises.readFile(controllerPath, 'utf-8'); const callPattern = - /messenger\.call\s*\(\s*['"]((RampsService|TransakService):[^'"]+)['"]/gu; + /messenger\.call\s*\(\s*['"]((RampsService|TransakService|NeoBankService):[^'"]+)['"]/gu; const calledActions = new Set(); let match: RegExpExecArray | null; while ((match = callPattern.exec(source)) !== null) { @@ -103,6 +105,7 @@ describe('RampsController', () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -179,6 +182,7 @@ describe('RampsController', () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2198,6 +2202,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2264,6 +2269,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -2306,6 +2312,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "orders": [], "providerAutoSelected": false, "userRegion": null, @@ -2324,6 +2331,7 @@ describe('RampsController', () => { ), ).toMatchInlineSnapshot(` { + "autoramps": [], "countries": { "data": [], "error": null, @@ -8935,6 +8943,195 @@ describe('RampsController', () => { }); }); + describe('autoramps', () => { + it('adds and removes autoramp accounts', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(controller.state.autoramps[0]?.id).toBe('ar-1'); + expect(controller.state.autoramps[0]?.status).toBe( + AutorampStatus.Authorized, + ); + + controller.removeAutoramp('ar-1'); + expect(controller.state.autoramps).toHaveLength(0); + }); + }); + + it('applies push snapshots and publishes notable transitions', async () => { + await withController(async ({ controller, messenger }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const events: unknown[] = []; + messenger.subscribe( + 'RampsController:autorampStatusChanged', + (payload) => { + events.push(payload); + }, + ); + + const updated = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + expect(updated.status).toBe(AutorampStatus.Approved); + expect(updated.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + shouldNotify: true, + }); + }); + }); + + it('refreshes autoramps via NeoBankService', async () => { + await withController(async ({ controller, rootMessenger }) => { + const getAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + getAutoramp, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramp('ar-1'); + expect(getAutoramp).toHaveBeenCalledWith('ar-1'); + expect(updated.status).toBe(AutorampStatus.Approved); + + await controller.refreshAutoramps(); + expect(getAutoramp).toHaveBeenCalledTimes(2); + }); + }); + + it('skips failed refreshes when refreshing all autoramps', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'NeoBankService:getAutoramp', + async (id: string) => { + if (id === 'ar-bad') { + throw new Error('network'); + } + return { + id, + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }; + }, + ); + + controller.addAutoramp({ + id: 'ar-bad', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + controller.addAutoramp({ + id: 'ar-good', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Authorized, + }); + + const updated = await controller.refreshAutoramps(); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe('ar-good'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-bad')?.status, + ).toBe(AutorampStatus.Authorized); + }); + }); + + it('syncs autoramps with user storage when gates pass', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => + ({ + isBackupAndSyncEnabled: true, + }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + // Allow any incremental push from addAutoramp to settle, then full sync. + await Promise.resolve(); + batchSet.mockClear(); + + await controller.syncAutorampsWithUserStorage(); + + expect(batchSet).toHaveBeenCalled(); + const [, entries] = batchSet.mock.calls[0] as [ + string, + [string, string][], + ]; + expect(entries[0]?.[0]).toBe('ar-1'); + expect(JSON.parse(entries[0]?.[1] ?? '{}').o.id).toBe('ar-1'); + }); + }); + + it('marks autoramp as notified', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + expect(controller.state.autoramps[0]?.notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); + }); + describe('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', @@ -11835,6 +12032,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, 'RemoteFeatureFlagController:getState', ], }); diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index aac160d8825..e3d41c7185a 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,6 +15,25 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + applyAutorampRemoteStatus, + createAutorampAccount, + markAutorampNotified, +} from './autorampAccount.js'; +import { + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal, + updateAutorampInRemoteStorage, +} from './autoramp-syncing/index.js'; +import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; +import type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +import type { NeoBankServiceActions } from './NeoBankService.js'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { UserStorageController } from '@metamask/profile-sync-controller'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, @@ -134,6 +153,7 @@ export const controllerName = 'RampsController'; export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] + | NeoBankServiceActions['type'] )[] = [ 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', @@ -170,8 +190,20 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelOrder', 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', + 'NeoBankService:getAutoramp', ]; +/** + * User Storage / auth actions needed for autoramp Backup & Sync. + * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. + */ +export const RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS = [ + 'UserStorageController:getState', + 'UserStorageController:performGetStorageAllFeatureEntries', + 'UserStorageController:performBatchSetStorage', + 'AuthenticationController:isSignedIn', +] as const; + /** * Default TTL for quotes requests (15 seconds). * Quotes are time-sensitive and should have a shorter cache duration. @@ -387,6 +419,12 @@ export type RampsControllerState = { * and persists them. */ orders: RampsOrder[]; + /** + * MoonPay Enterprise autoramp accounts (standing routes), separate from + * {@link RampsOrder} payment instances. Refreshed from remote on load / + * push; persisted for rediscovery and transition UX. + */ + autoramps: AutorampAccount[]; /** * Whether the currently selected provider was auto-selected by the system * (no order history, no Transak) rather than chosen by the user or derived @@ -448,6 +486,12 @@ const rampsControllerMetadata = { includeInStateLogs: true, usedInUi: true, }, + autoramps: { + persist: true, + includeInDebugSnapshot: true, + includeInStateLogs: true, + usedInUi: true, + }, providerAutoSelected: { persist: true, includeInDebugSnapshot: true, @@ -514,6 +558,7 @@ export function getDefaultRampsControllerState(): RampsControllerState { }, }, orders: [], + autoramps: [], providerAutoSelected: false, }; } @@ -638,7 +683,12 @@ type AllowedActions = | TransakServiceGetIdProofStatusAction | TransakServiceCancelOrderAction | TransakServiceCancelAllActiveOrdersAction - | TransakServiceGetActiveOrdersAction; + | TransakServiceGetActiveOrdersAction + | NeoBankServiceGetAutorampAction + | UserStorageController.UserStorageControllerGetStateAction + | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction + | UserStorageController.UserStorageControllerPerformBatchSetStorageAction + | AuthenticationController.AuthenticationControllerIsSignedInAction; /** * Published when the state of {@link RampsController} changes. @@ -657,12 +707,28 @@ export type RampsControllerOrderStatusChangedEvent = { payload: [{ order: RampsOrder; previousStatus: RampsOrderStatus }]; }; +/** + * Published when an autoramp account status transitions to a notable state + * that the UI has not yet notified for (e.g. Approved / Rejected). + */ +export type RampsControllerAutorampStatusChangedEvent = { + type: `${typeof controllerName}:autorampStatusChanged`; + payload: [ + { + autoramp: AutorampAccount; + previousStatus: AutorampAccount['status']; + shouldNotify: boolean; + }, + ]; +}; + /** * Events that {@link RampsControllerMessenger} exposes to other consumers. */ export type RampsControllerEvents = | RampsControllerStateChangeEvent - | RampsControllerOrderStatusChangedEvent; + | RampsControllerOrderStatusChangedEvent + | RampsControllerAutorampStatusChangedEvent; /** * Events from other messengers that {@link RampsController} subscribes to. @@ -811,6 +877,13 @@ const MESSENGER_EXPOSED_METHODS = [ 'getQuotes', 'addOrder', 'removeOrder', + 'addAutoramp', + 'removeAutoramp', + 'markAutorampAsNotified', + 'applyAutorampStatusFromPush', + 'refreshAutoramp', + 'refreshAutoramps', + 'syncAutorampsWithUserStorage', 'startOrderPolling', 'stopOrderPolling', 'getBuyWidgetData', @@ -890,6 +963,12 @@ export class RampsController extends BaseController< #initPromise: Promise | null = null; + #isAutorampSyncingInProgress = false; + + #isApplyingAutorampSyncChanges = false; + + #pendingRemoteAutorampDeletes: AutorampAccount[] = []; + /** * Clears the pending resource count map. Used only in tests to exercise the * defensive path when get() returns undefined in the finally block. @@ -2437,6 +2516,290 @@ export class RampsController extends BaseController< this.#orderPollingMeta.delete(providerOrderId); } + // === AUTORAMP ACCOUNT MANAGEMENT === + + /** + * Whether a full autoramp User Storage sync is currently running. + */ + get isAutorampSyncingInProgress(): boolean { + return this.#isAutorampSyncingInProgress; + } + + /** + * Sets the autoramp sync semaphore (used by autoramp-syncing module). + * + * @param value - Whether sync is in progress. + */ + setIsAutorampSyncingInProgress(value: boolean): void { + this.#isAutorampSyncingInProgress = value; + } + + /** + * Sets whether local mutations are applying remote sync results + * (suppresses incremental remote pushes). + * + * @param value - Whether sync changes are being applied locally. + */ + setIsApplyingAutorampSyncChanges(value: boolean): void { + this.#isApplyingAutorampSyncChanges = value; + } + + /** + * Returns autoramps deleted locally while a full sync held the semaphore. + * + * @returns Pending remote delete queue. + */ + getPendingRemoteAutorampDeletes(): AutorampAccount[] { + return [...this.#pendingRemoteAutorampDeletes]; + } + + /** + * Clears acknowledged pending remote deletes after tombstones are written. + * + * @param accounts - Accounts whose remote tombstones were persisted. + */ + acknowledgePendingRemoteAutorampDeletes(accounts: AutorampAccount[]): void { + if (accounts.length === 0) { + return; + } + const keys = new Set(accounts.map((account) => account.id)); + this.#pendingRemoteAutorampDeletes = + this.#pendingRemoteAutorampDeletes.filter( + (account) => !keys.has(account.id), + ); + } + + #getAutorampSyncingOptions() { + return { + getRampsControllerInstance: () => this, + getMessenger: () => this.messenger, + }; + } + + /** + * Adds or updates a local autoramp account (e.g. after `POST /api/autoramps`). + * When Backup & Sync is available, also pushes an incremental User Storage update + * unless a full sync is applying remote changes. + * + * @param accountOrInput - Full account or create fields. + * @returns The upserted {@link AutorampAccount}. + */ + addAutoramp( + accountOrInput: + | AutorampAccount + | { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampAccount['status'] | string; + }, + ): AutorampAccount { + const account = + 'updatedAt' in accountOrInput && 'lastSeenStatus' in accountOrInput + ? (accountOrInput as AutorampAccount) + : createAutorampAccount(accountOrInput); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (existing) => existing.id === account.id, + ); + if (idx === -1) { + state.autoramps.push(account as Draft); + } else { + state.autoramps[idx] = { + ...state.autoramps[idx], + ...account, + } as Draft; + } + }); + + const upserted = + this.state.autoramps.find((existing) => existing.id === account.id) ?? + account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + + /** + * Removes a local autoramp account by id. + * Soft-deletes the remote User Storage entry when sync is available. + * + * @param autorampId - MoonPay autoramp id. + */ + removeAutoramp(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + + this.update((state) => { + state.autoramps = state.autoramps.filter( + (autoramp) => autoramp.id !== autorampId, + ); + }); + + if (!existing || this.#isApplyingAutorampSyncChanges) { + return; + } + + if (this.#isAutorampSyncingInProgress) { + this.#pendingRemoteAutorampDeletes.push(existing); + return; + } + + deleteAutorampInRemoteStorage( + existing, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + /** + * Marks that the UI has already notified for the autoramp's current status. + * + * @param autorampId - MoonPay autoramp id. + */ + markAutorampAsNotified(autorampId: string): void { + const existing = this.state.autoramps.find( + (autoramp) => autoramp.id === autorampId, + ); + if (!existing) { + return; + } + const notified = markAutorampNotified(existing); + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === autorampId, + ); + if (idx !== -1) { + state.autoramps[idx] = notified as Draft; + } + }); + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + notified, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + } + + /** + * Applies a remote autoramp snapshot from a websocket / webhook push. + * Uses the same compare helper as refresh-on-load. + * + * @param remote - Remote autoramp snapshot. + * @returns The updated local account. + */ + applyAutorampStatusFromPush( + remote: AutorampRemoteSnapshot, + ): AutorampAccount { + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Fetches one autoramp from the Ramp API neo-bank proxy and applies it. + * + * @param autorampId - MoonPay autoramp id. + * @returns The updated local account. + */ + async refreshAutoramp(autorampId: string): Promise { + const remote = await this.messenger.call( + 'NeoBankService:getAutoramp', + autorampId, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + + /** + * Refreshes all known local autoramps from remote. + * Intended for app load / unlock catch-up when websockets were missed. + * + * @returns Updated autoramp accounts (failed fetches are skipped). + */ + async refreshAutoramps(): Promise { + const ids = this.state.autoramps.map((autoramp) => autoramp.id); + const updated: AutorampAccount[] = []; + + for (const id of ids) { + try { + updated.push(await this.refreshAutoramp(id)); + } catch { + // Keep local state for this id; continue remaining refreshes. + } + } + + return updated; + } + + /** + * Bidirectional sync of autoramp accounts with MetaMask User Storage + * (feature `rampsAutoramps`). No-ops when Backup & Sync / auth gates fail. + * + * @param config - Optional error callbacks for Sentry / logging. + */ + async syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig = {}, + ): Promise { + await syncAutorampsWithUserStorageInternal( + config, + this.#getAutorampSyncingOptions(), + ); + } + + #applyAutorampRemoteSnapshot(remote: AutorampRemoteSnapshot): AutorampAccount { + const local = + this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? null; + const result = applyAutorampRemoteStatus(local, remote); + + this.update((state) => { + const idx = state.autoramps.findIndex( + (autoramp) => autoramp.id === result.account.id, + ); + if (idx === -1) { + state.autoramps.push(result.account as Draft); + } else { + state.autoramps[idx] = result.account as Draft; + } + }); + + if (result.statusChanged) { + this.messenger.publish('RampsController:autorampStatusChanged', { + autoramp: result.account, + previousStatus: result.previousStatus, + shouldNotify: result.shouldNotify, + }); + } + + const upserted = + this.state.autoramps.find( + (autoramp) => autoramp.id === result.account.id, + ) ?? result.account; + + if ( + !this.#isApplyingAutorampSyncChanges && + !this.#isAutorampSyncingInProgress + ) { + updateAutorampInRemoteStorage( + upserted, + this.#getAutorampSyncingOptions(), + ).catch(() => undefined); + } + + return upserted; + } + /** * Refreshes a single order via the V2 API and updates it in state. * Publishes orderStatusChanged if the status transitioned. diff --git a/packages/ramps-controller/src/autoramp-syncing/constants.ts b/packages/ramps-controller/src/autoramp-syncing/constants.ts new file mode 100644 index 00000000000..c69c57ea629 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/constants.ts @@ -0,0 +1,25 @@ +/** + * User Storage feature key for MoonPay Enterprise autoramp accounts. + * Each autoramp is stored as a separate entry under this feature. + */ +export const USER_STORAGE_RAMPS_AUTORAMPS_FEATURE = 'rampsAutoramps'; + +/** + * Key for version in User Storage schema. + */ +export const USER_STORAGE_VERSION_KEY = 'v'; + +/** + * Current version of the autoramp User Storage schema. + */ +export const USER_STORAGE_VERSION = '1'; + +/** + * Trace names for autoramp syncing operations. + */ +export const TraceName = { + AutorampSyncFull: 'Ramps Autoramp Sync Full', + AutorampSyncSaveBatch: 'Ramps Autoramp Sync Save Batch', + AutorampSyncUpdateRemote: 'Ramps Autoramp Sync Update Remote', + AutorampSyncDeleteRemote: 'Ramps Autoramp Sync Delete Remote', +} as const; diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts new file mode 100644 index 00000000000..3834b2c5cf2 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -0,0 +1,429 @@ +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { + AutorampSyncingOptions, + SyncAutorampAccount, + SyncAutorampsWithUserStorageConfig, + UserStorageAutorampEntry, +} from './types.js'; + +function getAutorampTimestamp(account: SyncAutorampAccount): number { + return account.updatedAt ?? 0; +} + +/** + * Builds the local/remote merge plan for autoramp sync. + * + * @param localAccounts - Syncable local accounts. + * @param validRemoteAccounts - Syncable remote accounts. + * @returns Local mutations and remote uploads to apply. + */ +export function computeAutorampMergePlan( + localAccounts: SyncAutorampAccount[], + validRemoteAccounts: SyncAutorampAccount[], +): { + remoteAccountsMap: Map; + accountsToAddOrUpdateLocally: SyncAutorampAccount[]; + accountsToDeleteLocally: SyncAutorampAccount[]; + accountsToUpdateRemotely: SyncAutorampAccount[]; +} { + const localAccountsMap = new Map(); + const remoteAccountsMap = new Map(); + + localAccounts.forEach((account) => { + localAccountsMap.set(createAutorampStorageKey(account), account); + }); + validRemoteAccounts.forEach((account) => { + remoteAccountsMap.set(createAutorampStorageKey(account), account); + }); + + const accountsToAddOrUpdateLocally: SyncAutorampAccount[] = []; + const accountsToDeleteLocally: SyncAutorampAccount[] = []; + const accountsToUpdateRemotely: SyncAutorampAccount[] = []; + + for (const remoteAccount of validRemoteAccounts) { + const key = createAutorampStorageKey(remoteAccount); + const localAccount = localAccountsMap.get(key); + + if (remoteAccount.deletedAt) { + if (localAccount) { + const localTimestamp = getAutorampTimestamp(localAccount); + if (localTimestamp > remoteAccount.deletedAt) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToDeleteLocally.push(remoteAccount); + } + } + } else if (!localAccount) { + accountsToAddOrUpdateLocally.push(remoteAccount); + } else if (!areAutorampsEqual(localAccount, remoteAccount)) { + const localTimestamp = getAutorampTimestamp(localAccount); + const remoteTimestamp = getAutorampTimestamp(remoteAccount); + if (localTimestamp >= remoteTimestamp) { + accountsToUpdateRemotely.push(localAccount); + } else { + accountsToAddOrUpdateLocally.push(remoteAccount); + } + } + } + + for (const localAccount of localAccounts) { + const key = createAutorampStorageKey(localAccount); + if (!remoteAccountsMap.has(key)) { + accountsToUpdateRemotely.push(localAccount); + } + } + + return { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + }; +} + +async function getRemoteAutoramps( + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig, +): Promise { + const { getMessenger } = options; + const { onAutorampSyncErroneousSituation } = config; + + const remoteJsonArray = + (await getMessenger().call( + 'UserStorageController:performGetStorageAllFeatureEntries', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + )) ?? []; + + if (remoteJsonArray.length === 0) { + return []; + } + + const remoteAccounts: SyncAutorampAccount[] = []; + for (const entryJson of remoteJsonArray) { + try { + const entry = JSON.parse(entryJson) as UserStorageAutorampEntry; + if (entry[USER_STORAGE_VERSION_KEY] !== USER_STORAGE_VERSION) { + onAutorampSyncErroneousSituation?.( + 'Unsupported autoramp storage version', + { + version: entry[USER_STORAGE_VERSION_KEY], + expectedVersion: USER_STORAGE_VERSION, + }, + ); + continue; + } + if (!entry.o || typeof entry.o !== 'object') { + onAutorampSyncErroneousSituation?.( + 'Remote autoramp entry missing payload', + {}, + ); + continue; + } + const mapped = mapUserStorageEntryToAutoramp(entry); + if (!createAutorampStorageKey(mapped)) { + continue; + } + remoteAccounts.push(mapped); + } catch (error) { + onAutorampSyncErroneousSituation?.( + 'Failed to parse remote autoramp entry', + { error, entryLength: entryJson.length }, + ); + } + } + + return remoteAccounts; +} + +async function saveAutorampsToUserStorage( + accounts: SyncAutorampAccount[], + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { getMessenger, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + const save = async () => { + const storageEntries: [string, string][] = []; + for (const account of accounts) { + const key = createAutorampStorageKey(account); + if (!key) { + onAutorampSyncErroneousSituation?.( + 'Skipping autoramp remote write with empty storage key', + { hasId: Boolean(account.id) }, + ); + continue; + } + storageEntries.push([ + key, + JSON.stringify(mapAutorampToUserStorageEntry(account)), + ]); + } + if (storageEntries.length === 0) { + return; + } + await getMessenger().call( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + storageEntries, + ); + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncSaveBatch, + data: { autorampCount: accounts.length }, + }, + save, + ); + return; + } + await save(); +} + +/** + * Syncs autoramp accounts between local controller state and User Storage. + * + * @param config - Optional error callbacks. + * @param options - Sync options (controller + messenger). + */ +export async function syncAutorampsWithUserStorage( + config: SyncAutorampsWithUserStorageConfig, + options: AutorampSyncingOptions, +): Promise { + const { getRampsControllerInstance, trace } = options; + const { onAutorampSyncErroneousSituation } = config; + + if (!canPerformAutorampSyncing(options)) { + return; + } + + const controller = getRampsControllerInstance(); + controller.setIsAutorampSyncingInProgress(true); + + try { + const validRemoteAccounts = (await getRemoteAutoramps(options, config)).filter( + (account: SyncAutorampAccount) => + Boolean(account.deletedAt) || isSyncableAutoramp(account), + ); + + const performSync = async () => { + const getLocalAccounts = () => + controller.state.autoramps.filter(isSyncableAutoramp); + + const pendingDeleteKeysBeforeApply = new Set( + controller + .getPendingRemoteAutorampDeletes() + .map((account) => createAutorampStorageKey(account)) + .filter((key) => key.length > 0), + ); + + const { + remoteAccountsMap, + accountsToAddOrUpdateLocally, + accountsToDeleteLocally, + accountsToUpdateRemotely, + } = computeAutorampMergePlan(getLocalAccounts(), validRemoteAccounts); + + controller.setIsApplyingAutorampSyncChanges(true); + try { + for (const account of accountsToDeleteLocally) { + controller.removeAutoramp(createAutorampStorageKey(account)); + } + for (const account of accountsToAddOrUpdateLocally) { + if ( + !account.deletedAt && + !pendingDeleteKeysBeforeApply.has(createAutorampStorageKey(account)) + ) { + controller.addAutoramp(stripAutorampSyncMetadata(account)); + } + } + } finally { + controller.setIsApplyingAutorampSyncChanges(false); + } + + const localKeys = new Set( + getLocalAccounts().map((account) => createAutorampStorageKey(account)), + ); + const pendingDeletes = controller + .getPendingRemoteAutorampDeletes() + .filter((account) => { + const key = createAutorampStorageKey(account); + return key.length > 0 && !localKeys.has(key); + }); + const pendingDeleteKeys = new Set( + pendingDeletes.map((account) => createAutorampStorageKey(account)), + ); + + const now = Date.now(); + const uploads: SyncAutorampAccount[] = [ + ...accountsToUpdateRemotely + .filter( + (account) => + !pendingDeleteKeys.has(createAutorampStorageKey(account)), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + // Local-only accounts already included via merge plan; also upload + // accounts present locally that differ after apply. + ...getLocalAccounts() + .filter((account) => { + const key = createAutorampStorageKey(account); + if (pendingDeleteKeys.has(key)) { + return false; + } + const remote = remoteAccountsMap.get(key); + return !remote || !areAutorampsEqual(account, remote); + }) + .filter( + (account) => + !accountsToUpdateRemotely.some( + (planned) => + createAutorampStorageKey(planned) === + createAutorampStorageKey(account), + ), + ) + .map((account) => ({ + ...account, + updatedAt: account.updatedAt || now, + })), + ...pendingDeletes.map((account) => ({ + ...account, + deletedAt: now, + updatedAt: now, + })), + ]; + + // Dedupe by key, prefer later entries + const uploadMap = new Map(); + for (const account of uploads) { + uploadMap.set(createAutorampStorageKey(account), account); + } + + if (uploadMap.size > 0) { + await saveAutorampsToUserStorage( + [...uploadMap.values()], + options, + config, + ); + controller.acknowledgePendingRemoteAutorampDeletes(pendingDeletes); + } + }; + + if (trace) { + await trace( + { + name: TraceName.AutorampSyncFull, + data: { + localAutorampCount: controller.state.autoramps.filter( + isSyncableAutoramp, + ).length, + remoteAutorampCount: validRemoteAccounts.length, + }, + }, + performSync, + ); + return; + } + + await performSync(); + } catch (error) { + onAutorampSyncErroneousSituation?.('Error synchronizing autoramps', { + error, + }); + throw error; + } finally { + controller.setIsAutorampSyncingInProgress(false); + } +} + +/** + * Updates a single autoramp in remote storage without a full sync. + * + * @param account - Local autoramp that changed. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function updateAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const update = async () => { + if ( + !canPerformAutorampSyncing(options) || + !isSyncableAutoramp(account) + ) { + return; + } + await saveAutorampsToUserStorage( + [{ ...account, updatedAt: Date.now() }], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncUpdateRemote }, update); + return; + } + await update(); +} + +/** + * Soft-deletes an autoramp in remote storage. + * + * @param account - Autoramp to tombstone remotely. + * @param options - Sync options. + * @param config - Optional error callbacks. + */ +export async function deleteAutorampInRemoteStorage( + account: SyncAutorampAccount, + options: AutorampSyncingOptions, + config: SyncAutorampsWithUserStorageConfig = {}, +): Promise { + const { trace } = options; + + const remove = async () => { + if (!canPerformAutorampSyncing(options) || !account.id) { + return; + } + const now = Date.now(); + await saveAutorampsToUserStorage( + [ + { + ...account, + deletedAt: now, + updatedAt: now, + }, + ], + options, + config, + ); + }; + + if (trace) { + await trace({ name: TraceName.AutorampSyncDeleteRemote }, remove); + return; + } + await remove(); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts new file mode 100644 index 00000000000..4b00ece4354 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -0,0 +1,76 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + areAutorampsEqual, + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, +} from './format-utils.js'; + +describe('autoramp-syncing/format-utils', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + updatedAt: 1000, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }); + + it('creates storage keys from id', () => { + expect(createAutorampStorageKey(account)).toBe('ar-1'); + expect(createAutorampStorageKey('ar-2')).toBe('ar-2'); + }); + + it('detects syncable autoramps', () => { + expect(isSyncableAutoramp(account)).toBe(true); + expect(isSyncableAutoramp({ id: '' })).toBe(false); + expect(isSyncableAutoramp(null)).toBe(false); + }); + + it('maps to user storage without deposit rails', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + notifiedForStatus: AutorampStatus.Approved, + }); + + expect(entry).toStrictEqual({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + lu: 1000, + }); + expect(entry.o).not.toHaveProperty('depositRailsSummary'); + }); + + it('round-trips storage entries and strips deletedAt', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + deletedAt: 2000, + }); + const mapped = mapUserStorageEntryToAutoramp(entry); + expect(mapped.deletedAt).toBe(2000); + expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt'); + }); + + it('compares sync-relevant fields', () => { + expect(areAutorampsEqual(account, { ...account })).toBe(true); + expect( + areAutorampsEqual(account, { + ...account, + status: AutorampStatus.Authorized, + }), + ).toBe(false); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts new file mode 100644 index 00000000000..81669481ace --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts @@ -0,0 +1,127 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { normalizeAutorampStatus } from '../autorampAccount.js'; +import { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import type { + SyncAutorampAccount, + UserStorageAutorampEntry, +} from './types.js'; + +/** + * Storage key for an autoramp entry (MoonPay autoramp id). + * + * @param account - Autoramp account or id-bearing object. + * @returns Storage key string. + */ +export function createAutorampStorageKey( + account: Pick | string, +): string { + return typeof account === 'string' ? account : account.id; +} + +/** + * Whether an autoramp has the minimum fields required to sync. + * + * @param account - Candidate autoramp. + * @returns True when syncable. + */ +export function isSyncableAutoramp( + account: Partial | null | undefined, +): account is AutorampAccount { + return Boolean( + account && + typeof account.id === 'string' && + account.id.length > 0 && + typeof account.customerId === 'string' && + typeof account.walletAddress === 'string' && + account.status, + ); +} + +/** + * Map a local autoramp to a User Storage entry (strips depositRailsSummary). + * + * @param account - Local or sync-aware autoramp. + * @returns Compact storage entry. + */ +export function mapAutorampToUserStorageEntry( + account: SyncAutorampAccount, +): UserStorageAutorampEntry { + const now = Date.now(); + return { + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: account.id, + customerId: account.customerId, + walletAddress: account.walletAddress, + status: account.status, + lastSeenStatus: account.lastSeenStatus, + ...(account.notifiedForStatus + ? { notifiedForStatus: account.notifiedForStatus } + : {}), + }, + lu: account.updatedAt || now, + ...(account.deletedAt ? { dt: account.deletedAt } : {}), + }; +} + +/** + * Map a User Storage entry back to a sync-aware autoramp account. + * + * @param entry - Remote storage entry. + * @returns Sync autoramp (no depositRailsSummary). + */ +export function mapUserStorageEntryToAutoramp( + entry: UserStorageAutorampEntry, +): SyncAutorampAccount { + return { + id: entry.o.id, + customerId: entry.o.customerId, + walletAddress: entry.o.walletAddress, + status: normalizeAutorampStatus(entry.o.status), + lastSeenStatus: normalizeAutorampStatus(entry.o.lastSeenStatus), + ...(entry.o.notifiedForStatus + ? { + notifiedForStatus: normalizeAutorampStatus(entry.o.notifiedForStatus), + } + : {}), + updatedAt: entry.lu ?? Date.now(), + ...(entry.dt ? { deletedAt: entry.dt } : {}), + }; +} + +/** + * Strip sync-only metadata before writing into controller state. + * + * @param account - Sync-aware autoramp. + * @returns Plain {@link AutorampAccount}. + */ +export function stripAutorampSyncMetadata( + account: SyncAutorampAccount, +): AutorampAccount { + const { deletedAt: _deletedAt, ...rest } = account; + return rest; +} + +/** + * Compare syncable fields for equality (ignores depositRailsSummary). + * + * @param left - First account. + * @param right - Second account. + * @returns True when sync-relevant fields match. + */ +export function areAutorampsEqual( + left: SyncAutorampAccount, + right: SyncAutorampAccount, +): boolean { + return ( + left.id === right.id && + left.customerId === right.customerId && + left.walletAddress === right.walletAddress && + left.status === right.status && + left.lastSeenStatus === right.lastSeenStatus && + left.notifiedForStatus === right.notifiedForStatus + ); +} diff --git a/packages/ramps-controller/src/autoramp-syncing/index.ts b/packages/ramps-controller/src/autoramp-syncing/index.ts new file mode 100644 index 00000000000..f8dd8064634 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/index.ts @@ -0,0 +1,28 @@ +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, + TraceName, +} from './constants.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './types.js'; +export { + createAutorampStorageKey, + isSyncableAutoramp, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, + stripAutorampSyncMetadata, + areAutorampsEqual, +} from './format-utils.js'; +export { canPerformAutorampSyncing } from './sync-utils.js'; +export { + computeAutorampMergePlan, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, +} from './controller-integration.js'; diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts new file mode 100644 index 00000000000..c619447ad23 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts @@ -0,0 +1,128 @@ +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { computeAutorampMergePlan } from './controller-integration.js'; +import { canPerformAutorampSyncing } from './sync-utils.js'; +import type { AutorampSyncingOptions } from './types.js'; + +describe('autoramp-syncing/sync-utils', () => { + it('returns false when messenger actions are unavailable', () => { + const options: AutorampSyncingOptions = { + getMessenger: () => + ({ + call: () => { + throw new Error('not delegated'); + }, + }) as AutorampSyncingOptions['getMessenger'] extends () => infer R + ? R + : never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: () => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + }; + + expect(canPerformAutorampSyncing(options)).toBe(false); + }); + + it('returns true when B&S and auth gates pass', () => { + const call = jest.fn((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw new Error(`unexpected ${action}`); + }); + + const options = { + getMessenger: () => ({ call }) as never, + getRampsControllerInstance: () => ({ + state: { autoramps: [] }, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn(), + removeAutoramp: jest.fn(), + getPendingRemoteAutorampDeletes: () => [], + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + }), + } as AutorampSyncingOptions; + + expect(canPerformAutorampSyncing(options)).toBe(true); + }); +}); + +describe('autoramp-syncing/computeAutorampMergePlan', () => { + it('imports remote-only accounts and uploads local-only accounts', () => { + const local = createAutorampAccount({ + id: 'local-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = createAutorampAccount({ + id: 'remote-1', + customerId: 'c', + walletAddress: '0x2', + status: AutorampStatus.Approved, + updatedAt: 20, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally.map((a) => a.id)).toStrictEqual([ + 'remote-1', + ]); + expect(plan.accountsToUpdateRemotely.map((a) => a.id)).toStrictEqual([ + 'local-1', + ]); + }); + + it('prefers newer timestamp on conflicts', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 50, + }); + const remote = { + ...createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Approved, + updatedAt: 10, + }), + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToUpdateRemotely).toHaveLength(1); + expect(plan.accountsToAddOrUpdateLocally).toHaveLength(0); + }); + + it('applies remote tombstones when local is older', () => { + const local = createAutorampAccount({ + id: 'ar-1', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + updatedAt: 10, + }); + const remote = { + ...local, + deletedAt: 20, + updatedAt: 20, + }; + + const plan = computeAutorampMergePlan([local], [remote]); + expect(plan.accountsToDeleteLocally).toHaveLength(1); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts new file mode 100644 index 00000000000..735bc6f65be --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts @@ -0,0 +1,48 @@ +import type { AutorampSyncingOptions } from './types.js'; + +/** + * Check if we can perform autoramp User Storage syncing. + * + * Requires Backup & Sync enabled, signed-in auth, and no in-progress sync. + * Optional `isRampsSyncingEnabled` on User Storage state defaults to true when absent. + * + * @param options - Sync options. + * @returns Whether sync can run. + */ +export function canPerformAutorampSyncing( + options: AutorampSyncingOptions, +): boolean { + const { getMessenger, getRampsControllerInstance } = options; + + try { + const userStorageState = getMessenger().call( + 'UserStorageController:getState', + ) as { + isBackupAndSyncEnabled?: boolean; + isRampsSyncingEnabled?: boolean; + }; + + const isBackupAndSyncEnabled = Boolean( + userStorageState.isBackupAndSyncEnabled, + ); + const isRampsSyncingEnabled = userStorageState.isRampsSyncingEnabled ?? true; + const isAuthEnabled = getMessenger().call( + 'AuthenticationController:isSignedIn', + ); + const { isAutorampSyncingInProgress } = getRampsControllerInstance(); + + if ( + !isBackupAndSyncEnabled || + !isRampsSyncingEnabled || + isAutorampSyncingInProgress || + !isAuthEnabled + ) { + return false; + } + + return true; + } catch { + // Host has not delegated User Storage / auth actions yet. + return false; + } +} diff --git a/packages/ramps-controller/src/autoramp-syncing/types.ts b/packages/ramps-controller/src/autoramp-syncing/types.ts new file mode 100644 index 00000000000..b3b732e6192 --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/types.ts @@ -0,0 +1,70 @@ +import type { TraceCallback } from '@metamask/controller-utils'; + +import type { AutorampAccount } from '../autorampAccount.js'; +import type { RampsControllerMessenger } from '../RampsController.js'; +import type { + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; + +/** + * Compact User Storage entry for an autoramp account. + * Omits deposit rail details — those are re-fetched from the Ramp API / MoonPay. + */ +export type UserStorageAutorampEntry = { + [USER_STORAGE_VERSION_KEY]: typeof USER_STORAGE_VERSION; + o: { + id: string; + customerId: string; + walletAddress: string; + status: string; + lastSeenStatus: string; + notifiedForStatus?: string; + }; + lu?: number; + dt?: number; +}; + +/** + * {@link AutorampAccount} plus optional soft-delete metadata for sync merge. + */ +export type SyncAutorampAccount = AutorampAccount & { + deletedAt?: number; +}; + +/** + * Minimal controller surface required by autoramp syncing. + */ +export type AutorampSyncingController = { + state: { + autoramps: AutorampAccount[]; + }; + readonly isAutorampSyncingInProgress: boolean; + setIsAutorampSyncingInProgress: (value: boolean) => void; + setIsApplyingAutorampSyncChanges: (value: boolean) => void; + addAutoramp: (account: AutorampAccount) => AutorampAccount; + removeAutoramp: (autorampId: string) => void; + getPendingRemoteAutorampDeletes: () => AutorampAccount[]; + acknowledgePendingRemoteAutorampDeletes: ( + accounts: AutorampAccount[], + ) => void; +}; + +/** + * Options for autoramp syncing operations. + */ +export type AutorampSyncingOptions = { + getRampsControllerInstance: () => AutorampSyncingController; + getMessenger: () => RampsControllerMessenger; + trace?: TraceCallback; +}; + +/** + * Optional callbacks for sync error reporting. + */ +export type SyncAutorampsWithUserStorageConfig = { + onAutorampSyncErroneousSituation?: ( + errorMessage: string, + sentryContext?: Record, + ) => void; +}; diff --git a/packages/ramps-controller/src/autorampAccount.test.ts b/packages/ramps-controller/src/autorampAccount.test.ts new file mode 100644 index 00000000000..d5518956a98 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.test.ts @@ -0,0 +1,163 @@ +import type { + ApplyAutorampRemoteStatusResult, + AutorampAccount, + AutorampRemoteSnapshot, +} from './autorampAccount.js'; +import { + AutorampStatus, + applyAutorampRemoteStatus, + createAutorampAccount, + isTerminalAutorampStatus, + markAutorampNotified, + normalizeAutorampStatus, +} from './autorampAccount.js'; + +describe('autorampAccount', () => { + describe('normalizeAutorampStatus', () => { + it('returns known statuses as-is', () => { + expect(normalizeAutorampStatus(AutorampStatus.Approved)).toBe( + AutorampStatus.Approved, + ); + expect(normalizeAutorampStatus('DepositAccountAdded')).toBe( + AutorampStatus.DepositAccountAdded, + ); + }); + + it('falls back to Created for unknown values', () => { + expect(normalizeAutorampStatus('Nope')).toBe(AutorampStatus.Created); + }); + }); + + describe('isTerminalAutorampStatus', () => { + it('identifies terminal statuses', () => { + expect(isTerminalAutorampStatus(AutorampStatus.Rejected)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Cancelled)).toBe(true); + expect(isTerminalAutorampStatus(AutorampStatus.Approved)).toBe(false); + expect(isTerminalAutorampStatus(AutorampStatus.Authorized)).toBe(false); + }); + }); + + describe('createAutorampAccount', () => { + it('defaults status to Authorized and mirrors lastSeenStatus', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + updatedAt: 1000, + }); + + expect(account).toStrictEqual({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + updatedAt: 1000, + depositRailsSummary: undefined, + }); + }); + }); + + describe('applyAutorampRemoteStatus', () => { + const baseLocal: AutorampAccount = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + updatedAt: 1, + }); + + it('creates a local account without notify when local is null', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true, currency: 'EUR' }, + }; + + const result = applyAutorampRemoteStatus(null, remote); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.depositRailsSummary).toStrictEqual({ + ready: true, + currency: 'EUR', + }); + }); + + it('detects Approved transition and requests notify once', () => { + const remote: AutorampRemoteSnapshot = { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + depositRailsSummary: { ready: true }, + }; + + const result = applyAutorampRemoteStatus(baseLocal, remote); + + expect(result).toMatchObject({ + previousStatus: AutorampStatus.Authorized, + statusChanged: true, + shouldNotify: true, + } satisfies Partial); + expect(result.account.status).toBe(AutorampStatus.Approved); + expect(result.account.lastSeenStatus).toBe(AutorampStatus.Authorized); + }); + + it('does not notify again when already notified for that status', () => { + const local = markAutorampNotified({ + ...baseLocal, + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Authorized, + notifiedForStatus: AutorampStatus.Approved, + }); + + const result = applyAutorampRemoteStatus(local, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(result.statusChanged).toBe(false); + expect(result.shouldNotify).toBe(false); + }); + + it('does not notify for non-notable transitions', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.DepositAccountAdded, + }); + + expect(result.statusChanged).toBe(true); + expect(result.shouldNotify).toBe(false); + }); + + it('notifies for Rejected', () => { + const result = applyAutorampRemoteStatus(baseLocal, { + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Rejected, + }); + + expect(result.shouldNotify).toBe(true); + }); + }); + + describe('markAutorampNotified', () => { + it('sets notifiedForStatus to current status', () => { + const account = createAutorampAccount({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + + expect(markAutorampNotified(account).notifiedForStatus).toBe( + AutorampStatus.Approved, + ); + }); + }); +}); diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts new file mode 100644 index 00000000000..0cdec9615a6 --- /dev/null +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -0,0 +1,221 @@ +/** + * Local + remote models for MoonPay Enterprise autoramp accounts. + * Separate from {@link RampsOrder}: autoramps are standing routes; orders are payments. + */ + +/** + * Autoramp lifecycle statuses from MoonPay Enterprise. + * @see https://dev.enterprise.moonpay.com/autoramp-status + */ +export enum AutorampStatus { + Created = 'Created', + Authorized = 'Authorized', + EditPending = 'EditPending', + DepositAccountAdded = 'DepositAccountAdded', + Approved = 'Approved', + Rejected = 'Rejected', + Cancelled = 'Cancelled', +} + +/** + * Non-PII deposit readiness summary cached after a remote refresh. + * Full deposit rail details (IBAN, etc.) should be re-fetched when needed — not synced. + */ +export type AutorampDepositRailsSummary = { + /** Source currency code when known (e.g. EUR). */ + currency?: string; + /** True when the autoramp is approved and deposit details may be shared. */ + ready: boolean; +}; + +/** + * Local controller representation of an autoramp account. + */ +export type AutorampAccount = { + /** MoonPay autoramp id. */ + id: string; + /** MoonPay customer id. */ + customerId: string; + /** Destination wallet address associated with this autoramp. */ + walletAddress: string; + /** Latest status from MoonPay (source of truth after refresh). */ + status: AutorampStatus; + /** + * Status observed before the most recent remote apply. + * Used for transition UX / analytics (e.g. Authorized → Approved). + */ + lastSeenStatus: AutorampStatus; + /** + * Last status for which the UI already showed a notification. + * Prevents duplicate toasts across refresh and push. + */ + notifiedForStatus?: AutorampStatus; + /** Epoch ms of the last local update from remote or push. */ + updatedAt: number; + /** Optional non-PII deposit readiness cache. */ + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Minimal remote snapshot from `GET /api/autoramps/{id}` (or a push payload). + * Host apps / BFF map MoonPay responses into this shape. + */ +export type AutorampRemoteSnapshot = { + id: string; + customerId: string; + walletAddress?: string; + status: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; +}; + +/** + * Result of applying a remote autoramp snapshot onto local state. + */ +export type ApplyAutorampRemoteStatusResult = { + account: AutorampAccount; + previousStatus: AutorampStatus; + statusChanged: boolean; + /** True when status changed and UI has not yet notified for the new status. */ + shouldNotify: boolean; +}; + +/** + * Terminal autoramp statuses — no further lifecycle progress expected. + */ +export const TERMINAL_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Statuses that commonly warrant user-visible transition UX (toast / banner). + */ +export const NOTABLE_AUTORAMP_STATUSES: ReadonlySet = new Set([ + AutorampStatus.Approved, + AutorampStatus.Rejected, + AutorampStatus.Cancelled, +]); + +/** + * Whether an autoramp status is terminal. + * + * @param status - Status to test. + * @returns Whether the status is terminal. + */ +export function isTerminalAutorampStatus(status: AutorampStatus): boolean { + return TERMINAL_AUTORAMP_STATUSES.has(status); +} + +/** + * Normalize a remote status string into {@link AutorampStatus}. + * Unknown values fall back to {@link AutorampStatus.Created}. + * + * @param status - Remote status string. + * @returns A known {@link AutorampStatus}. + */ +export function normalizeAutorampStatus( + status: AutorampStatus | string, +): AutorampStatus { + if (Object.values(AutorampStatus).includes(status as AutorampStatus)) { + return status as AutorampStatus; + } + return AutorampStatus.Created; +} + +/** + * Build a new local autoramp account from create/response fields. + * + * @param input - Required identity + status fields. + * @returns A new {@link AutorampAccount}. + */ +export function createAutorampAccount(input: { + id: string; + customerId: string; + walletAddress: string; + status?: AutorampStatus | string; + depositRailsSummary?: AutorampDepositRailsSummary; + updatedAt?: number; +}): AutorampAccount { + const status = normalizeAutorampStatus( + input.status ?? AutorampStatus.Authorized, + ); + return { + id: input.id, + customerId: input.customerId, + walletAddress: input.walletAddress, + status, + lastSeenStatus: status, + updatedAt: input.updatedAt ?? Date.now(), + depositRailsSummary: input.depositRailsSummary, + }; +} + +/** + * Apply a remote autoramp snapshot onto a local account for transition detection. + * Pure helper — shared by refresh-on-load and websocket push paths. + * + * @param local - Current local account (or null when first upserting from remote). + * @param remote - Remote snapshot (MoonPay GET or push). + * @returns Updated account plus change / notify flags. + */ +export function applyAutorampRemoteStatus( + local: AutorampAccount | null, + remote: AutorampRemoteSnapshot, +): ApplyAutorampRemoteStatusResult { + const remoteStatus = normalizeAutorampStatus(remote.status); + + if (!local) { + const account = createAutorampAccount({ + id: remote.id, + customerId: remote.customerId, + walletAddress: remote.walletAddress ?? '', + status: remoteStatus, + depositRailsSummary: remote.depositRailsSummary, + }); + return { + account, + previousStatus: remoteStatus, + statusChanged: false, + shouldNotify: false, + }; + } + + const previousStatus = local.status; + const statusChanged = previousStatus !== remoteStatus; + const shouldNotify = + statusChanged && + local.notifiedForStatus !== remoteStatus && + NOTABLE_AUTORAMP_STATUSES.has(remoteStatus); + + const account: AutorampAccount = { + ...local, + id: remote.id, + customerId: remote.customerId || local.customerId, + walletAddress: remote.walletAddress || local.walletAddress, + status: remoteStatus, + lastSeenStatus: previousStatus, + updatedAt: Date.now(), + depositRailsSummary: + remote.depositRailsSummary ?? local.depositRailsSummary, + }; + + return { + account, + previousStatus, + statusChanged, + shouldNotify, + }; +} + +/** + * Mark that the UI has notified for the account's current status. + * + * @param account - Account to update. + * @returns Account with `notifiedForStatus` set to current status. + */ +export function markAutorampNotified(account: AutorampAccount): AutorampAccount { + return { + ...account, + notifiedForStatus: account.status, + }; +} diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index f1d1dcdbe6d..eb84a47d863 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -6,6 +6,7 @@ export type { RampsControllerState, RampsControllerStateChangeEvent, RampsControllerOrderStatusChangedEvent, + RampsControllerAutorampStatusChangedEvent, RampsControllerOptions, UserRegion, ResourceState, @@ -29,6 +30,13 @@ export type { RampsControllerGetQuotesAction, RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, + RampsControllerAddAutorampAction, + RampsControllerRemoveAutorampAction, + RampsControllerMarkAutorampAsNotifiedAction, + RampsControllerApplyAutorampStatusFromPushAction, + RampsControllerRefreshAutorampAction, + RampsControllerRefreshAutorampsAction, + RampsControllerSyncAutorampsWithUserStorageAction, RampsControllerStartOrderPollingAction, RampsControllerStopOrderPollingAction, RampsControllerGetBuyWidgetDataAction, @@ -67,6 +75,7 @@ export { getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; export type { RampsServiceActions, @@ -165,6 +174,50 @@ export { TERMINAL_ORDER_STATUSES, isTerminalOrderStatus, } from './orderStatus.js'; +export type { + AutorampAccount, + AutorampDepositRailsSummary, + AutorampRemoteSnapshot, + ApplyAutorampRemoteStatusResult, +} from './autorampAccount.js'; +export { + AutorampStatus, + TERMINAL_AUTORAMP_STATUSES, + NOTABLE_AUTORAMP_STATUSES, + isTerminalAutorampStatus, + normalizeAutorampStatus, + createAutorampAccount, + applyAutorampRemoteStatus, + markAutorampNotified, +} from './autorampAccount.js'; +export type { + UserStorageAutorampEntry, + SyncAutorampAccount, + AutorampSyncingOptions, + SyncAutorampsWithUserStorageConfig, +} from './autoramp-syncing/index.js'; +export { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, + deleteAutorampInRemoteStorage, + canPerformAutorampSyncing, + computeAutorampMergePlan, + mapAutorampToUserStorageEntry, + mapUserStorageEntryToAutoramp, +} from './autoramp-syncing/index.js'; +export type { + NeoBankServiceActions, + NeoBankServiceEvents, + NeoBankServiceMessenger, + NeoBankAutorampResponse, +} from './NeoBankService.js'; +export type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +export { + NeoBankService, + serviceName as neoBankServiceName, + mapNeoBankAutorampToRemoteSnapshot, +} from './NeoBankService.js'; export type { TypedError } from './errorNormalization.js'; export { getErrorMessage, From 69643826df977ff801c11e3026742dba4117bc18 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 11:37:34 -0600 Subject: [PATCH 50/67] feat(transaction-pay-controller): add Money Account vault deposit and withdraw actions Money Account mUSD that arrives from an external payout (MoonPay/Iron) can only be vaulted by CHOMP's delayed auto-sweep, and there is no reusable path for redeeming vmUSD straight to a partner deposit address. Add two messenger actions so the client can drive both directions itself. --- README.md | 1 + .../transaction-pay-controller/CHANGELOG.md | 5 + .../transaction-pay-controller/package.json | 1 + ...actionPayController-method-action-types.ts | 28 +++ .../src/TransactionPayController.test.ts | 122 ++++++++++ .../src/TransactionPayController.ts | 70 ++++++ .../transaction-pay-controller/src/index.ts | 4 + .../src/tests/messenger-mock.ts | 8 + .../transaction-pay-controller/src/types.ts | 10 + .../src/utils/ma-vault-deposit.test.ts | 47 +++- .../src/utils/ma-vault-deposit.ts | 87 +++++-- .../src/utils/ma-vault-payout.test.ts | 198 ++++++++++++++++ .../src/utils/ma-vault-payout.ts | 81 +++++++ .../src/utils/ma-vault-withdraw.test.ts | 212 ++++++++++++++++++ .../src/utils/ma-vault-withdraw.ts | 130 +++++++++++ .../utils/money-account-vault-config.test.ts | 90 ++++++++ .../src/utils/money-account-vault-config.ts | 86 +++++++ .../tsconfig.build.json | 3 + .../transaction-pay-controller/tsconfig.json | 3 + yarn.lock | 3 +- 20 files changed, 1171 insertions(+), 18 deletions(-) create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-payout.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts create mode 100644 packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts create mode 100644 packages/transaction-pay-controller/src/utils/money-account-vault-config.ts diff --git a/README.md b/README.md index 55139eccac1..4a0b3fa22b9 100644 --- a/README.md +++ b/README.md @@ -643,6 +643,7 @@ linkStyle default opacity:0.5 transaction_pay_controller --> gas_fee_controller; transaction_pay_controller --> keyring_controller; transaction_pay_controller --> messenger; + transaction_pay_controller --> money_account_utils; transaction_pay_controller --> network_controller; transaction_pay_controller --> ramps_controller; transaction_pay_controller --> remote_feature_flag_controller; diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 28b5678e75a..16883e3ce3f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849)) + ### Changed - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 3ac7dd8ee89..eec01336006 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -67,6 +67,7 @@ "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/metamask-eth-abis": "^3.1.1", + "@metamask/money-account-utils": "^1.1.0", "@metamask/network-controller": "^35.0.1", "@metamask/ramps-controller": "^20.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0", diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 14a91436fa2..1f0578a6c3e 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -49,6 +49,32 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { handler: TransactionPayController['updateFiatPayment']; }; +/** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { + type: `TransactionPayController:submitMoneyAccountVaultDeposit`; + handler: TransactionPayController['submitMoneyAccountVaultDeposit']; +}; + +/** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ +export type TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction = { + type: `TransactionPayController:submitMoneyAccountVaultWithdraw`; + handler: TransactionPayController['submitMoneyAccountVaultWithdraw']; +}; + /** * Gets the delegation transaction for a given transaction. * @@ -144,6 +170,8 @@ export type TransactionPayControllerMethodActions = | TransactionPayControllerSetTransactionConfigAction | TransactionPayControllerUpdatePaymentTokenAction | TransactionPayControllerUpdateFiatPaymentAction + | TransactionPayControllerSubmitMoneyAccountVaultDepositAction + | TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction | TransactionPayControllerGetDelegationTransactionAction | TransactionPayControllerGetAmountDataAction | TransactionPayControllerGetFiatOptionsAction diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 467f6406ab2..0701c877516 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -16,6 +16,8 @@ import type { UpdateTransactionDataCallback, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -31,6 +33,8 @@ jest.mock('./utils/source-amounts'); jest.mock('./utils/quotes'); jest.mock('./utils/transaction'); jest.mock('./utils/feature-flags'); +jest.mock('./utils/ma-vault-payout'); +jest.mock('./utils/ma-vault-withdraw'); const TRANSACTION_ID_MOCK = '123-456'; const TRANSACTION_META_MOCK = { id: TRANSACTION_ID_MOCK } as TransactionMeta; @@ -50,6 +54,12 @@ describe('TransactionPayController', () => { ); const subscribeAssetChangesMock = jest.mocked(subscribeAssetChanges); const getStrategyOrderMock = jest.mocked(getStrategyOrder); + const submitMoneyAccountVaultDepositFromPayoutMock = jest.mocked( + submitMoneyAccountVaultDepositFromPayout, + ); + const submitMoneyAccountVaultWithdrawUtilMock = jest.mocked( + submitMoneyAccountVaultWithdrawUtil, + ); let messenger: TransactionPayControllerMessenger; let getKeyringControllerStateMock: jest.Mock; @@ -106,6 +116,118 @@ describe('TransactionPayController', () => { }); }); + describe('Money Account vault actions', () => { + const moneyAccountAddress = + '0x1111111111111111111111111111111111111111' as Hex; + const transactionHash = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; + const recipient = '0x2222222222222222222222222222222222222222' as Hex; + + it('exposes the payout deposit action through the messenger', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + createController(); + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultDeposit', + { + moneyAccountAddress, + transactionHash, + }, + ); + + expect(submitMoneyAccountVaultDepositFromPayoutMock).toHaveBeenCalledWith( + { moneyAccountAddress, transactionHash }, + messenger, + ); + expect(result).toStrictEqual({ transactionHash }); + }); + + it('deduplicates concurrent payout deposit actions by transaction hash', async () => { + let resolveSubmit: + | ((value: { transactionHash?: Hex }) => void) + | undefined; + submitMoneyAccountVaultDepositFromPayoutMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = controller.submitMoneyAccountVaultDeposit(request); + const second = controller.submitMoneyAccountVaultDeposit(request); + resolveSubmit?.({ transactionHash }); + + expect(await first).toStrictEqual({ transactionHash }); + expect(await second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('exposes the exact-out withdraw action through the messenger', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + createController(); + const request = { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: '0x8f' as Hex, + moneyAccountAddress, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient, + requestId: 'request-id', + tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, + }; + + const result = await messenger.call( + 'TransactionPayController:submitMoneyAccountVaultWithdraw', + request, + ); + + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledWith( + request, + messenger, + ); + expect(result).toStrictEqual({ batchId: '0x123' }); + }); + + it('deduplicates concurrent withdraw actions by request ID', async () => { + let resolveSubmit: ((value: { batchId: Hex }) => void) | undefined; + submitMoneyAccountVaultWithdrawUtilMock.mockImplementation( + async () => + await new Promise((resolve) => { + resolveSubmit = resolve; + }), + ); + const controller = createController(); + const request = { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: '0x8f' as Hex, + moneyAccountAddress, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient, + requestId: 'request-id', + tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, + }; + + const first = controller.submitMoneyAccountVaultWithdraw(request); + const second = controller.submitMoneyAccountVaultWithdraw(request); + resolveSubmit?.({ batchId: '0x123' }); + + expect(await first).toStrictEqual({ batchId: '0x123' }); + expect(await second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + }); + describe('updatePaymentToken', () => { it('calls util', () => { createController().updatePaymentToken({ diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0f35951e683..c623162e1c9 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -29,6 +29,10 @@ import type { UpdatePaymentTokenRequest, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw as submitMoneyAccountVaultWithdrawUtil } from './utils/ma-vault-withdraw.js'; import { updateQuotes } from './utils/quotes.js'; import { updateSourceAmounts } from './utils/source-amounts.js'; import { @@ -45,6 +49,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'polymarketGetDepositWalletAddress', 'polymarketSubmitDepositWalletBatch', 'setTransactionConfig', + 'submitMoneyAccountVaultDeposit', + 'submitMoneyAccountVaultWithdraw', 'updateFiatPayment', 'updatePaymentToken', ] as const; @@ -87,6 +93,16 @@ export class TransactionPayController extends BaseController< readonly #resolveSourceAmount?: ResolveSourceAmountCallback; + readonly #vaultDepositRequests = new Map< + string, + Promise<{ transactionHash?: `0x${string}` }> + >(); + + readonly #vaultWithdrawRequests = new Map< + string, + Promise<{ batchId: `0x${string}` }> + >(); + constructor({ fiatOptions, getAmountData, @@ -215,6 +231,60 @@ export class TransactionPayController extends BaseController< }); } + /** + * Vaults mUSD received in a completed Iron payout transaction. + * + * Concurrent calls for the same payout hash share one in-flight submission. + * + * @param request - Completed Iron payout details. + * @returns Hash of the confirmed vault transaction. + */ + submitMoneyAccountVaultDeposit( + request: SubmitMoneyAccountVaultDepositRequest, + ): Promise<{ transactionHash?: `0x${string}` }> { + const key = request.transactionHash.toLowerCase(); + const current = this.#vaultDepositRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultDepositFromPayout( + request, + this.messenger, + ).finally(() => { + this.#vaultDepositRequests.delete(key); + }); + this.#vaultDepositRequests.set(key, pending); + return pending; + } + + /** + * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. + * + * Concurrent calls with the same request ID share one in-flight batch setup. + * + * @param request - Backend-bound exact-out Iron intent. + * @returns Pending transaction batch ID. + */ + submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + ): Promise<{ batchId: `0x${string}` }> { + const key = request.requestId; + const current = this.#vaultWithdrawRequests.get(key); + if (current) { + return current; + } + + const pending = submitMoneyAccountVaultWithdrawUtil( + request, + this.messenger, + ).finally(() => { + this.#vaultWithdrawRequests.delete(key); + }); + this.#vaultWithdrawRequests.set(key, pending); + return pending; + } + /** * Gets the delegation transaction for a given transaction. * diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 1d52593f72e..8b6fadbda0f 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -39,9 +39,13 @@ export type { TransactionPayControllerPolymarketGetDepositWalletAddressAction, TransactionPayControllerPolymarketSubmitDepositWalletBatchAction, TransactionPayControllerSetTransactionConfigAction, + TransactionPayControllerSubmitMoneyAccountVaultDepositAction, + TransactionPayControllerSubmitMoneyAccountVaultWithdrawAction, TransactionPayControllerUpdatePaymentTokenAction, TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; +export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; export { TransactionPayPublishHook } from './helpers/TransactionPayPublishHook.js'; diff --git a/packages/transaction-pay-controller/src/tests/messenger-mock.ts b/packages/transaction-pay-controller/src/tests/messenger-mock.ts index f81bbf7516d..b9f63a01d41 100644 --- a/packages/transaction-pay-controller/src/tests/messenger-mock.ts +++ b/packages/transaction-pay-controller/src/tests/messenger-mock.ts @@ -70,6 +70,8 @@ export function getMessengerMock({ TransactionControllerAddTransactionBatchAction['handler'] > = jest.fn(); + const getMoneyAccountBalanceMock = jest.fn(); + const findNetworkClientIdByChainIdMock: jest.MockedFn< NetworkControllerFindNetworkClientIdByChainIdAction['handler'] > = jest.fn(); @@ -191,6 +193,11 @@ export function getMessengerMock({ addTransactionBatchMock, ); + messenger.registerActionHandler( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + getMoneyAccountBalanceMock, + ); + messenger.registerActionHandler( 'NetworkController:findNetworkClientIdByChainId', findNetworkClientIdByChainIdMock, @@ -320,6 +327,7 @@ export function getMessengerMock({ getGasFeeControllerStateMock, getGasFeeTokensMock, getKeyringControllerStateMock, + getMoneyAccountBalanceMock, getNetworkClientByIdMock, getNetworkConfigurationByChainIdMock, getRemoteFeatureFlagControllerStateMock, diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index b8ee97a3990..4f70d5f702c 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -61,6 +61,15 @@ import type { } from './constants.js'; import type { TransactionPayControllerMethodActions } from './TransactionPayController-method-action-types.js'; +type MoneyAccountBalanceServiceGetMoneyAccountBalanceAction = { + type: 'MoneyAccountBalanceService:getMoneyAccountBalance'; + handler: (accountAddress: Hex) => Promise<{ + musdBalance: string; + totalBalance: string; + vmusdValueInMusd: string; + }>; +}; + export type AllowedActions = | AccountTrackerControllerGetStateAction | AssetsControllerGetStateForTransactionPayAction @@ -68,6 +77,7 @@ export type AllowedActions = | GetGasFeeState | KeyringControllerGetStateAction | KeyringControllerSignTypedMessageAction + | MoneyAccountBalanceServiceGetMoneyAccountBalanceAction | NetworkControllerFindNetworkClientIdByChainIdAction | NetworkControllerGetNetworkClientByIdAction | NetworkControllerGetNetworkConfigurationByChainIdAction diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 1aca3ef304c..337cb27ba8d 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -7,7 +7,10 @@ import type { Hex } from '@metamask/utils'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; -import { submitMoneyAccountVaultDeposit } from './ma-vault-deposit.js'; +import { + submitMoneyAccountVaultDeposit, + submitMoneyAccountVaultDepositBatch, +} from './ma-vault-deposit.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -512,4 +515,46 @@ describe('submitMoneyAccountVaultDeposit', () => { expect(findRecentChompVaultDepositMock).not.toHaveBeenCalled(); }); }); + + describe('parentless vault batches', () => { + const depositCalls: BatchTransactionParams[] = [ + { data: '0xapprove' as Hex, to: '0xapprove' as Hex }, + { data: '0xdeposit' as Hex, to: '0xdeposit' as Hex }, + ]; + + it('submits without updating a parent transaction', async () => { + const callMock = jest.fn((action: string) => { + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: 'batch-id' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + + expect(updateTransactionMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0xvault' }); + }); + + it('returns before submission when disabled', async () => { + const callMock = jest.fn(); + + const result = await submitMoneyAccountVaultDepositBatch({ + depositCalls, + messenger: buildMessenger(callMock), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS_MOCK, + sourceAmountRaw: '5000000', + vaultDisabled: true, + }); + + expect(callMock).not.toHaveBeenCalled(); + expect(result).toStrictEqual({ transactionHash: '0x' }); + }); + }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 8f3facf82f5..84d52d00bbc 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -92,6 +92,59 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: nestedTransactions, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled: false, + }); +} + +/** + * Submits pre-built Money Account vault calls without requiring a parent + * transaction. When `transactionId` is supplied, submitted child IDs are also + * linked to that parent for the existing Fiat and Relay flows. + * + * @param options - Submission options. + * @param options.depositCalls - Pre-built approve and deposit calls. + * @param options.fromBlock - Block at which to begin the CHOMP race check. + * @param options.messenger - Transaction Pay controller messenger. + * @param options.moneyAccountAddress - Money Account that owns the mUSD. + * @param options.sourceAmountRaw - Raw mUSD amount to deposit. + * @param options.transactionId - Optional parent transaction to link children. + * @param options.vaultDisabled - Whether vault submission is disabled. + * @returns Hash of the final confirmed vault transaction. + */ +export async function submitMoneyAccountVaultDepositBatch({ + depositCalls, + fromBlock, + messenger, + moneyAccountAddress, + sourceAmountRaw, + transactionId, + vaultDisabled, +}: { + depositCalls: NestedTransactionMetadata[]; + fromBlock?: Hex; + messenger: TransactionPayControllerMessenger; + moneyAccountAddress: Hex; + sourceAmountRaw: string; + transactionId?: string; + vaultDisabled: boolean; +}): Promise<{ transactionHash?: Hex }> { + if (vaultDisabled) { + log('Skipping vault deposit because vaultDisabled is true', { + moneyAccountAddress, + sourceAmountRaw, + transactionId, + }); + + return { transactionHash: '0x' }; + } + // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already // auto-vaulted the funds during or before the checkout window. const preChompHash = await tryFindChompDeposit({ @@ -117,23 +170,25 @@ export async function submitMoneyAccountVaultDeposit({ messenger, (id) => { transactionIds.push(id); - updateTransaction( - { - transactionId, - messenger, - note: 'Add required transaction ID from Money Account vault submission', - }, - (tx) => { - tx.requiredTransactionIds ??= []; - tx.requiredTransactionIds.push(id); - }, - ); + if (transactionId) { + updateTransaction( + { + transactionId, + messenger, + note: 'Add required transaction ID from Money Account vault submission', + }, + (tx) => { + tx.requiredTransactionIds ??= []; + tx.requiredTransactionIds.push(id); + }, + ); + } }, ); log('Submitting Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -151,7 +206,7 @@ export async function submitMoneyAccountVaultDeposit({ origin: ORIGIN_METAMASK, requireApproval: false, skipInitialGasEstimate: true, - transactions: nestedTransactions.map((nestedTransaction, index) => ({ + transactions: depositCalls.map((nestedTransaction, index) => ({ params: { data: nestedTransaction.data, to: nestedTransaction.to, @@ -185,7 +240,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Submitted Money Account vault deposit', { moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -209,7 +264,7 @@ export async function submitMoneyAccountVaultDeposit({ log('Confirmed Money Account vault deposit', { hash, moneyAccountAddress, - nestedTransactionCount: nestedTransactions.length, + nestedTransactionCount: depositCalls.length, networkClientId, sourceAmountRaw, transactionId, @@ -316,7 +371,7 @@ async function tryFindChompDeposit({ messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; - transactionId: string; + transactionId?: string; }): Promise { if (!fromBlock) { return undefined; diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts new file mode 100644 index 00000000000..4c4cb468068 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -0,0 +1,198 @@ +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { submitMoneyAccountVaultDepositFromPayout } from './ma-vault-payout.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./ma-vault-deposit'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); +jest.mock('./transaction'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const PAYOUT_HASH = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const VAULT_HASH = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: '0x3333333333333333333333333333333333333333' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444' as Hex, + tellerAddress: '0x5555555555555555555555555555555555555555' as Hex, +}; + +function getMessenger(): TransactionPayControllerMessenger { + return { + call: jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + throw new Error(`Unexpected action: ${action}`); + }), + } as unknown as TransactionPayControllerMessenger; +} + +describe('submitMoneyAccountVaultDepositFromPayout', () => { + const buildMoneyAccountDepositBatchMock = jest.mocked( + buildMoneyAccountDepositBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + const getTransferredAmountFromTxHashMock = jest.mocked( + getTransferredAmountFromTxHash, + ); + const submitMoneyAccountVaultDepositBatchMock = jest.mocked( + submitMoneyAccountVaultDepositBatch, + ); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: '5000000', + blockNumber: '0x123', + }); + buildMoneyAccountDepositBatchMock.mockResolvedValue({ + approveTx: { + params: { + data: '0xapprove', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + }, + depositTx: { + params: { + data: '0xdeposit', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + }, + } as never); + submitMoneyAccountVaultDepositBatchMock.mockResolvedValue({ + transactionHash: VAULT_HASH, + }); + }); + + it('resolves the Iron payout and submits a parentless vault batch', async () => { + const messenger = getMessenger(); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledWith({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: PAYOUT_HASH, + walletAddress: MONEY_ACCOUNT_ADDRESS, + }); + expect(buildMoneyAccountDepositBatchMock).toHaveBeenCalledWith({ + amount: 5000000n, + provider: expect.anything(), + ...VAULT_CONFIG, + }); + expect(submitMoneyAccountVaultDepositBatchMock).toHaveBeenCalledWith({ + depositCalls: [ + expect.objectContaining({ data: '0xapprove' }), + expect.objectContaining({ data: '0xdeposit' }), + ], + fromBlock: '0x123', + messenger, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: '5000000', + vaultDisabled: false, + }); + expect(result).toStrictEqual({ transactionHash: VAULT_HASH }); + }); + + it('defaults vaultDisabled to false', async () => { + const messenger = getMessenger(); + + await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + }, + messenger, + ); + + expect(getTransferredAmountFromTxHashMock).toHaveBeenCalledTimes(1); + }); + + it('rejects a payout without an mUSD transfer to the Money Account', async () => { + getTransferredAmountFromTxHashMock.mockResolvedValue({ + amountRaw: undefined, + blockNumber: '0x123', + }); + + await expect( + submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ), + ).rejects.toThrow('Payout transaction has no mUSD transfer'); + + expect(buildMoneyAccountDepositBatchMock).not.toHaveBeenCalled(); + expect(submitMoneyAccountVaultDepositBatchMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when vaulting is disabled', async () => { + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: true, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); + + it('returns without resolving the payout when deposits are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + const result = await submitMoneyAccountVaultDepositFromPayout( + { + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + transactionHash: PAYOUT_HASH, + vaultDisabled: false, + }, + getMessenger(), + ); + + expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts new file mode 100644 index 00000000000..d181f057dae --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -0,0 +1,81 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { buildMoneyAccountDepositBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; +import { getTransferredAmountFromTxHash } from './transaction.js'; + +export type SubmitMoneyAccountVaultDepositRequest = { + moneyAccountAddress: Hex; + transactionHash: Hex; + vaultDisabled?: boolean; +}; + +/** + * Resolves an Iron payout transaction and vaults the received mUSD. + * + * @param request - Iron payout details. + * @param messenger - Transaction Pay controller messenger. + * @returns Hash of the confirmed vault transaction, or `0x` when disabled. + */ +export async function submitMoneyAccountVaultDepositFromPayout( + request: SubmitMoneyAccountVaultDepositRequest, + messenger: TransactionPayControllerMessenger, +): Promise<{ transactionHash?: Hex }> { + const { + moneyAccountAddress, + transactionHash, + vaultDisabled = false, + } = request; + + if ( + vaultDisabled || + !isMoneyAccountVaultActionEnabled(messenger, 'deposit') + ) { + return { transactionHash: '0x' }; + } + + const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ + chainId: CHAIN_ID_MONAD, + messenger, + tokenAddress: MUSD_MONAD_ADDRESS, + txHash: transactionHash, + walletAddress: moneyAccountAddress, + }); + + if (!amountRaw || BigInt(amountRaw) <= 0n) { + throw new Error('Payout transaction has no mUSD transfer'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { approveTx, depositTx } = await buildMoneyAccountDepositBatch({ + amount: BigInt(amountRaw), + provider, + ...vaultConfig, + }); + + return await submitMoneyAccountVaultDepositBatch({ + depositCalls: [ + { ...approveTx.params, type: approveTx.type }, + { ...depositTx.params, type: depositTx.type }, + ], + fromBlock: blockNumber, + messenger, + moneyAccountAddress, + sourceAmountRaw: amountRaw, + vaultDisabled: false, + }); +} diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts new file mode 100644 index 00000000000..a2bdd242e5b --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -0,0 +1,212 @@ +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { Hex } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultWithdrawRequest } from './ma-vault-withdraw.js'; +import { submitMoneyAccountVaultWithdraw } from './ma-vault-withdraw.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +jest.mock('@metamask/money-account-utils'); +jest.mock('./money-account-vault-config'); +jest.mock('./provider'); + +const MONEY_ACCOUNT_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const IRON_ADDRESS = '0x2222222222222222222222222222222222222222' as Hex; +const PROVIDER = { request: jest.fn() }; +const NETWORK_CLIENT_ID = 'monad-network-client'; +const VAULT_CONFIG = { + accountantAddress: '0x3333333333333333333333333333333333333333' as Hex, + boringVault: '0x4444444444444444444444444444444444444444' as Hex, + chainId: CHAIN_ID_MONAD, + lensAddress: '0x5555555555555555555555555555555555555555' as Hex, + tellerAddress: '0x6666666666666666666666666666666666666666' as Hex, +}; + +function getRequest( + overrides: Partial = {}, +): SubmitMoneyAccountVaultWithdrawRequest { + return { + amountInRaw: '5000000', + autorampId: 'autoramp-id', + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + quoteId: 'quote-id', + quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), + recipient: IRON_ADDRESS, + requestId: 'request-id', + tokenAddress: MUSD_MONAD_ADDRESS, + ...overrides, + }; +} + +function getMessenger({ + balance = '5000000', +}: { + balance?: string; +} = {}): { + callMock: jest.Mock; + messenger: TransactionPayControllerMessenger; +} { + const callMock = jest.fn((action: string) => { + if (action === 'NetworkController:getNetworkClientById') { + return { provider: PROVIDER }; + } + if (action === 'MoneyAccountBalanceService:getMoneyAccountBalance') { + return Promise.resolve({ + musdBalance: '0', + totalBalance: balance, + vmusdValueInMusd: balance, + }); + } + if (action === 'TransactionController:addTransactionBatch') { + return Promise.resolve({ batchId: '0xbatch' }); + } + throw new Error(`Unexpected action: ${action}`); + }); + + return { + callMock, + messenger: { + call: callMock, + } as unknown as TransactionPayControllerMessenger, + }; +} + +describe('submitMoneyAccountVaultWithdraw', () => { + const buildMoneyAccountWithdrawBatchMock = jest.mocked( + buildMoneyAccountWithdrawBatch, + ); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); + const isMoneyAccountVaultActionEnabledMock = jest.mocked( + isMoneyAccountVaultActionEnabled, + ); + const getNetworkClientIdMock = jest.mocked(getNetworkClientId); + + beforeEach(() => { + jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue(VAULT_CONFIG); + isMoneyAccountVaultActionEnabledMock.mockReturnValue(true); + getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID); + buildMoneyAccountWithdrawBatchMock.mockResolvedValue({ + transferTx: { + params: { + data: '0xtransfer', + to: MUSD_MONAD_ADDRESS, + value: '0x0', + }, + type: 'tokenMethodTransfer', + }, + withdrawTx: { + params: { + data: '0xwithdraw', + to: VAULT_CONFIG.tellerAddress, + value: '0x0', + }, + type: 'moneyAccountWithdraw', + }, + } as never); + }); + + it('creates one user-confirmed atomic batch to the Iron address', async () => { + const { callMock, messenger } = getMessenger(); + const request = getRequest(); + + const result = await submitMoneyAccountVaultWithdraw(request, messenger); + + expect(buildMoneyAccountWithdrawBatchMock).toHaveBeenCalledWith({ + accountantAddress: VAULT_CONFIG.accountantAddress, + amount: 5000000n, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + provider: expect.anything(), + recipient: IRON_ADDRESS, + tellerAddress: VAULT_CONFIG.tellerAddress, + }); + expect(callMock).toHaveBeenCalledWith( + 'TransactionController:addTransactionBatch', + expect.objectContaining({ + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: MONEY_ACCOUNT_ADDRESS, + isGasFeeSponsored: true, + isInternal: true, + networkClientId: NETWORK_CLIENT_ID, + origin: 'metamask', + requestId: 'request-id', + requireApproval: true, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ data: '0xwithdraw' }), + }), + expect.objectContaining({ + params: expect.objectContaining({ data: '0xtransfer' }), + }), + ], + }), + ); + expect(result).toStrictEqual({ batchId: '0xbatch' }); + }); + + it('rejects an amount above the withdrawable vmUSD value', async () => { + const { messenger } = getMessenger({ balance: '4999999' }); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), messenger), + ).rejects.toThrow('Insufficient withdrawable vmUSD balance'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it('rejects when Money Account withdrawals are disabled', async () => { + isMoneyAccountVaultActionEnabledMock.mockReturnValue(false); + + await expect( + submitMoneyAccountVaultWithdraw(getRequest(), getMessenger().messenger), + ).rejects.toThrow('Money Account vault withdrawal is disabled'); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], + [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], + [{ quoteValidUntil: 'invalid' }, 'Iron quote expiry is invalid'], + [ + { quoteValidUntil: new Date(Date.now() - 1_000).toISOString() }, + 'Iron quote has expired', + ], + [{ chainId: '0x1' }, 'Pix withdrawal must use Monad'], + [ + { tokenAddress: '0x7777777777777777777777777777777777777777' }, + 'Pix withdrawal must use mUSD', + ], + [{ recipient: '0x1234' }, 'Iron recipient is invalid'], + [ + { recipient: MONEY_ACCOUNT_ADDRESS }, + 'Iron recipient must differ from the Money Account', + ], + [{ requestId: '' }, 'Missing Iron request identifiers'], + [{ quoteId: '' }, 'Missing Iron request identifiers'], + [{ autorampId: '' }, 'Missing Iron request identifiers'], + ])('rejects invalid exact-out input %#', async (overrides, message) => { + await expect( + submitMoneyAccountVaultWithdraw( + getRequest(overrides), + getMessenger().messenger, + ), + ).rejects.toThrow(message); + + expect(buildMoneyAccountWithdrawBatchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts new file mode 100644 index 00000000000..bedf2a6a366 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -0,0 +1,130 @@ +import { Web3Provider } from '@ethersproject/providers'; +import { ORIGIN_METAMASK } from '@metamask/controller-utils'; +import { buildMoneyAccountWithdrawBatch } from '@metamask/money-account-utils'; +import type { TransactionBatchResult } from '@metamask/transaction-controller'; +import type { Hex } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; +import { getNetworkClientId } from './provider.js'; + +export type SubmitMoneyAccountVaultWithdrawRequest = { + amountInRaw: string; + autorampId: string; + chainId: Hex; + moneyAccountAddress: Hex; + quoteId: string; + quoteValidUntil: string; + recipient: Hex; + requestId: string; + tokenAddress: Hex; +}; + +/** + * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. + * + * @param request - Backend-bound exact-out Iron intent. + * @param messenger - Transaction Pay controller messenger. + * @returns The pending transaction batch ID. + */ +export async function submitMoneyAccountVaultWithdraw( + request: SubmitMoneyAccountVaultWithdrawRequest, + messenger: TransactionPayControllerMessenger, +): Promise { + validateRequest(request); + + if (!isMoneyAccountVaultActionEnabled(messenger, 'withdraw')) { + throw new Error('Money Account vault withdrawal is disabled'); + } + + const amount = BigInt(request.amountInRaw); + const balance = await messenger.call( + 'MoneyAccountBalanceService:getMoneyAccountBalance', + request.moneyAccountAddress, + ); + + if (amount > BigInt(balance.vmusdValueInMusd)) { + throw new Error('Insufficient withdrawable vmUSD balance'); + } + + const vaultConfig = getMoneyAccountVaultConfig(messenger); + const networkClientId = getNetworkClientId(messenger, CHAIN_ID_MONAD); + const networkClient = messenger.call( + 'NetworkController:getNetworkClientById', + networkClientId, + ); + const provider = new Web3Provider(networkClient.provider); + const { withdrawTx, transferTx } = await buildMoneyAccountWithdrawBatch({ + accountantAddress: vaultConfig.accountantAddress, + amount, + chainId: CHAIN_ID_MONAD, + moneyAccountAddress: request.moneyAccountAddress, + provider, + recipient: request.recipient, + tellerAddress: vaultConfig.tellerAddress, + }); + + return await messenger.call('TransactionController:addTransactionBatch', { + atomic: true, + disableHook: true, + disableSequential: true, + disableUpgrade: true, + from: request.moneyAccountAddress, + isGasFeeSponsored: true, + isInternal: true, + networkClientId, + origin: ORIGIN_METAMASK, + requestId: request.requestId, + requireApproval: true, + skipInitialGasEstimate: true, + transactions: [withdrawTx, transferTx], + }); +} + +function validateRequest( + request: SubmitMoneyAccountVaultWithdrawRequest, +): void { + if (!request.requestId || !request.quoteId || !request.autorampId) { + throw new Error('Missing Iron request identifiers'); + } + + let amount: bigint; + try { + amount = BigInt(request.amountInRaw); + } catch { + throw new Error('Withdrawal amount must be greater than zero'); + } + + if (amount <= 0n) { + throw new Error('Withdrawal amount must be greater than zero'); + } + + const expiry = Date.parse(request.quoteValidUntil); + if (Number.isNaN(expiry)) { + throw new Error('Iron quote expiry is invalid'); + } + if (expiry <= Date.now()) { + throw new Error('Iron quote has expired'); + } + + if (request.chainId !== CHAIN_ID_MONAD) { + throw new Error('Pix withdrawal must use Monad'); + } + if (request.tokenAddress.toLowerCase() !== MUSD_MONAD_ADDRESS.toLowerCase()) { + throw new Error('Pix withdrawal must use mUSD'); + } + if (!isValidHexAddress(request.recipient)) { + throw new Error('Iron recipient is invalid'); + } + if ( + request.recipient.toLowerCase() === + request.moneyAccountAddress.toLowerCase() + ) { + throw new Error('Iron recipient must differ from the Money Account'); + } +} diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts new file mode 100644 index 00000000000..82ff8e0a9ca --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.test.ts @@ -0,0 +1,90 @@ +import type { Hex, Json } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; +import { + getMoneyAccountVaultConfig, + isMoneyAccountVaultActionEnabled, +} from './money-account-vault-config.js'; + +const VAULT_CONFIG = { + accountantAddress: '0x2222222222222222222222222222222222222222', + boringVault: '0x3333333333333333333333333333333333333333', + chainId: CHAIN_ID_MONAD, + lensAddress: '0x4444444444444444444444444444444444444444', + tellerAddress: '0x5555555555555555555555555555555555555555', +}; + +function getMessenger( + flag: unknown, + moneyAccount: unknown = undefined, +): TransactionPayControllerMessenger { + return { + call: jest.fn(() => ({ + remoteFeatureFlags: { + moneyAccount: moneyAccount as Json, + moneyAccountVaultConfig: flag as Json, + }, + })), + } as unknown as TransactionPayControllerMessenger; +} + +describe('getMoneyAccountVaultConfig', () => { + it('returns a valid Monad vault config', () => { + expect( + getMoneyAccountVaultConfig(getMessenger(VAULT_CONFIG)), + ).toStrictEqual(VAULT_CONFIG as Record); + }); + + it.each([ + ['deposit', { moneyAccountDepositEnabled: true }], + ['withdraw', { moneyAccountWithdrawEnabled: true }], + ] as const)('returns true when %s is enabled', (action, flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + action, + ), + ).toBe(true); + }); + + it.each(['deposit', 'withdraw'] as const)( + 'defaults %s to disabled', + (action) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, {}), + action, + ), + ).toBe(false); + }, + ); + + it.each([undefined, [], 'enabled'])( + 'treats non-object Money Account flags as disabled', + (flag) => { + expect( + isMoneyAccountVaultActionEnabled( + getMessenger(VAULT_CONFIG, flag), + 'deposit', + ), + ).toBe(false); + }, + ); + + it('throws when vault config is missing', () => { + expect(() => getMoneyAccountVaultConfig(getMessenger(undefined))).toThrow( + 'Money Account vault config is unavailable', + ); + }); + + it.each([ + { ...VAULT_CONFIG, chainId: '0x1' }, + { ...VAULT_CONFIG, tellerAddress: '0x1234' }, + { ...VAULT_CONFIG, lensAddress: undefined }, + ])('throws when vault config is invalid', (config) => { + expect(() => getMoneyAccountVaultConfig(getMessenger(config))).toThrow( + 'Money Account vault config is invalid', + ); + }); +}); diff --git a/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts new file mode 100644 index 00000000000..5785eb2cd59 --- /dev/null +++ b/packages/transaction-pay-controller/src/utils/money-account-vault-config.ts @@ -0,0 +1,86 @@ +import type { Hex, Json } from '@metamask/utils'; +import { isValidHexAddress } from '@metamask/utils'; + +import { CHAIN_ID_MONAD } from '../constants.js'; +import type { TransactionPayControllerMessenger } from '../types.js'; + +const VAULT_CONFIG_FLAG = 'moneyAccountVaultConfig'; +const REQUIRED_ADDRESS_KEYS = [ + 'boringVault', + 'tellerAddress', + 'accountantAddress', + 'lensAddress', +] as const; + +type MoneyAccountVaultAction = 'deposit' | 'withdraw'; + +export type MoneyAccountVaultConfig = { + accountantAddress: Hex; + boringVault: Hex; + chainId: Hex; + lensAddress: Hex; + tellerAddress: Hex; +}; + +/** + * Reads and validates the Money Account vault configuration. + * + * @param messenger - Transaction Pay controller messenger. + * @returns Validated Monad vault configuration. + */ +export function getMoneyAccountVaultConfig( + messenger: TransactionPayControllerMessenger, +): MoneyAccountVaultConfig { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.[VAULT_CONFIG_FLAG]; + + if (value === undefined) { + throw new Error('Money Account vault config is unavailable'); + } + + if (!isVaultConfig(value)) { + throw new Error('Money Account vault config is invalid'); + } + + return value; +} + +/** + * Returns whether the requested Money Account vault action is enabled. + * + * @param messenger - Transaction Pay controller messenger. + * @param action - Vault action to inspect. + * @returns Whether the remote feature flag explicitly enables the action. + */ +export function isMoneyAccountVaultActionEnabled( + messenger: TransactionPayControllerMessenger, + action: MoneyAccountVaultAction, +): boolean { + const state = messenger.call('RemoteFeatureFlagController:getState'); + const value = state.remoteFeatureFlags?.moneyAccount; + if (!value || Array.isArray(value) || typeof value !== 'object') { + return false; + } + + const key = + action === 'deposit' + ? 'moneyAccountDepositEnabled' + : 'moneyAccountWithdrawEnabled'; + return value[key] === true; +} + +function isVaultConfig(value: Json): value is Json & MoneyAccountVaultConfig { + if ( + !value || + Array.isArray(value) || + typeof value !== 'object' || + value.chainId !== CHAIN_ID_MONAD + ) { + return false; + } + + return REQUIRED_ADDRESS_KEYS.every((key) => { + const address = value[key]; + return typeof address === 'string' && isValidHexAddress(address as Hex); + }); +} diff --git a/packages/transaction-pay-controller/tsconfig.build.json b/packages/transaction-pay-controller/tsconfig.build.json index 4865a1c8327..ad329d91746 100644 --- a/packages/transaction-pay-controller/tsconfig.build.json +++ b/packages/transaction-pay-controller/tsconfig.build.json @@ -39,6 +39,9 @@ { "path": "../messenger/tsconfig.build.json" }, + { + "path": "../money-account-utils/tsconfig.build.json" + }, { "path": "../sentinel-api-service/tsconfig.build.json" } diff --git a/packages/transaction-pay-controller/tsconfig.json b/packages/transaction-pay-controller/tsconfig.json index 67ae32f3465..fbae571a6cc 100644 --- a/packages/transaction-pay-controller/tsconfig.json +++ b/packages/transaction-pay-controller/tsconfig.json @@ -37,6 +37,9 @@ { "path": "../messenger" }, + { + "path": "../money-account-utils" + }, { "path": "../sentinel-api-service" } diff --git a/yarn.lock b/yarn.lock index c6b4cce7c4c..e13be51af24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7943,7 +7943,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/money-account-utils@workspace:packages/money-account-utils": +"@metamask/money-account-utils@npm:^1.1.0, @metamask/money-account-utils@workspace:packages/money-account-utils": version: 0.0.0-use.local resolution: "@metamask/money-account-utils@workspace:packages/money-account-utils" dependencies: @@ -9384,6 +9384,7 @@ __metadata: "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/metamask-eth-abis": "npm:^3.1.1" + "@metamask/money-account-utils": "npm:^1.1.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" From 4a9cb439ab86b47b80623209d9a18dc589e927d9 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:32:17 -0600 Subject: [PATCH 51/67] fix(transaction-pay-controller): harden Money Account vault deposit/withdraw Persist successful deposit/withdraw results for process-lifetime idempotency, tighten CHOMP matching to exact vault destination and amount, slim the withdraw request surface, and return { skipped: true } instead of a fake 0x hash when vaulting is disabled. --- .../transaction-pay-controller/CHANGELOG.md | 7 ++ ...actionPayController-method-action-types.ts | 7 +- .../src/TransactionPayController.test.ts | 88 +++++++++++++-- .../src/TransactionPayController.ts | 29 ++++- .../transaction-pay-controller/src/index.ts | 1 + .../src/strategy/fiat/FiatStrategy.test.ts | 13 +++ .../src/strategy/fiat/FiatStrategy.ts | 4 + .../src/strategy/fiat/fiat-direct-musd.ts | 3 +- .../src/strategy/fiat/fiat-submit.test.ts | 4 +- .../src/strategy/fiat/fiat-submit.ts | 6 +- .../transaction-pay-controller/src/types.ts | 1 + .../src/utils/chomp.test.ts | 103 ++++++++++++++---- .../src/utils/chomp.ts | 43 +++++++- .../src/utils/ma-vault-deposit.test.ts | 18 ++- .../src/utils/ma-vault-deposit.ts | 22 +++- .../src/utils/ma-vault-payout.test.ts | 4 +- .../src/utils/ma-vault-payout.ts | 8 +- .../src/utils/ma-vault-withdraw.test.ts | 21 +--- .../src/utils/ma-vault-withdraw.ts | 31 ++---- 19 files changed, 312 insertions(+), 101 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 16883e3ce3f..ce39a90775f 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -14,8 +14,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) - Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) +### Fixed + +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849)) + ## [26.3.0] ### Added diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 1f0578a6c3e..0301e9ba2fe 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -53,9 +53,12 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained so retries return the prior hash without + * submitting again. * * @param request - Completed Iron payout details. - * @returns Hash of the confirmed vault transaction. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { type: `TransactionPayController:submitMoneyAccountVaultDeposit`; @@ -66,6 +69,8 @@ export type TransactionPayControllerSubmitMoneyAccountVaultDepositAction = { * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. * * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. * * @param request - Backend-bound exact-out Iron intent. * @returns Pending transaction batch ID. diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 0701c877516..63c20eb62f3 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -168,6 +168,42 @@ describe('TransactionPayController', () => { ).toHaveBeenCalledTimes(1); }); + it('returns the prior result on retry after a successful deposit without resubmitting', async () => { + submitMoneyAccountVaultDepositFromPayoutMock.mockResolvedValue({ + transactionHash, + }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + const first = await controller.submitMoneyAccountVaultDeposit(request); + const second = await controller.submitMoneyAccountVaultDeposit(request); + + expect(first).toStrictEqual({ transactionHash }); + expect(second).toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(1); + }); + + it('retries after a failed deposit', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockRejectedValueOnce(new Error('vault failed')) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).rejects.toThrow('vault failed'); + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + it('exposes the exact-out withdraw action through the messenger', async () => { submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ batchId: '0x123' as Hex, @@ -175,14 +211,9 @@ describe('TransactionPayController', () => { createController(); const request = { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: '0x8f' as Hex, moneyAccountAddress, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient, requestId: 'request-id', - tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, }; const result = await messenger.call( @@ -208,14 +239,9 @@ describe('TransactionPayController', () => { const controller = createController(); const request = { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: '0x8f' as Hex, moneyAccountAddress, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient, requestId: 'request-id', - tokenAddress: '0x0F075aF77B28D77a60470472343B6E2941E3D17e' as Hex, }; const first = controller.submitMoneyAccountVaultWithdraw(request); @@ -226,6 +252,48 @@ describe('TransactionPayController', () => { expect(await second).toStrictEqual({ batchId: '0x123' }); expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); }); + + it('returns the same batchId on retry after approval is created without resubmitting', async () => { + submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ + batchId: '0x123' as Hex, + }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + const first = await controller.submitMoneyAccountVaultWithdraw(request); + const second = await controller.submitMoneyAccountVaultWithdraw(request); + + expect(first).toStrictEqual({ batchId: '0x123' }); + expect(second).toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(1); + }); + + it('retries withdraw after a failed batch setup', async () => { + submitMoneyAccountVaultWithdrawUtilMock + .mockRejectedValueOnce(new Error('batch failed')) + .mockResolvedValueOnce({ batchId: '0x123' as Hex }); + const controller = createController(); + const request = { + amountInRaw: '5000000', + moneyAccountAddress, + recipient, + requestId: 'request-id', + }; + + await expect( + controller.submitMoneyAccountVaultWithdraw(request), + ).rejects.toThrow('batch failed'); + + await expect( + controller.submitMoneyAccountVaultWithdraw(request), + ).resolves.toStrictEqual({ batchId: '0x123' }); + expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2); + }); }); describe('updatePaymentToken', () => { diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index c623162e1c9..0687182d265 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -29,6 +29,7 @@ import type { UpdatePaymentTokenRequest, } from './types.js'; import { getStrategyOrder } from './utils/feature-flags.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; import type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; import { submitMoneyAccountVaultDepositFromPayout } from './utils/ma-vault-payout.js'; import type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; @@ -93,11 +94,22 @@ export class TransactionPayController extends BaseController< readonly #resolveSourceAmount?: ResolveSourceAmountCallback; + /** + * In-flight and completed payout vault deposits, keyed by payout tx hash. + * Completed successes stay cached for the controller lifetime so webhook + * replays / retries do not re-submit. Preferable to persisted state here + * because vaulting is idempotent per process and avoids a state migration. + */ readonly #vaultDepositRequests = new Map< string, - Promise<{ transactionHash?: `0x${string}` }> + Promise >(); + /** + * In-flight and completed withdraw batch setups, keyed by requestId. + * Successful `addTransactionBatch` results stay cached for the controller + * lifetime so a second call cannot open another approval for the same id. + */ readonly #vaultWithdrawRequests = new Map< string, Promise<{ batchId: `0x${string}` }> @@ -235,13 +247,16 @@ export class TransactionPayController extends BaseController< * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. + * Successful results are retained so retries return the prior hash without + * submitting again. * * @param request - Completed Iron payout details. - * @returns Hash of the confirmed vault transaction. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ submitMoneyAccountVaultDeposit( request: SubmitMoneyAccountVaultDepositRequest, - ): Promise<{ transactionHash?: `0x${string}` }> { + ): Promise { const key = request.transactionHash.toLowerCase(); const current = this.#vaultDepositRequests.get(key); if (current) { @@ -251,8 +266,9 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultDepositFromPayout( request, this.messenger, - ).finally(() => { + ).catch((error: unknown) => { this.#vaultDepositRequests.delete(key); + throw error; }); this.#vaultDepositRequests.set(key, pending); return pending; @@ -262,6 +278,8 @@ export class TransactionPayController extends BaseController< * Creates a user-confirmed exact-out vmUSD withdrawal to Iron. * * Concurrent calls with the same request ID share one in-flight batch setup. + * Successful batch results are retained so a later call returns the same + * `batchId` without creating another approval. * * @param request - Backend-bound exact-out Iron intent. * @returns Pending transaction batch ID. @@ -278,8 +296,9 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultWithdrawUtil( request, this.messenger, - ).finally(() => { + ).catch((error: unknown) => { this.#vaultWithdrawRequests.delete(key); + throw error; }); this.#vaultWithdrawRequests.set(key, pending); return pending; diff --git a/packages/transaction-pay-controller/src/index.ts b/packages/transaction-pay-controller/src/index.ts index 8b6fadbda0f..0c430b4f470 100644 --- a/packages/transaction-pay-controller/src/index.ts +++ b/packages/transaction-pay-controller/src/index.ts @@ -45,6 +45,7 @@ export type { TransactionPayControllerUpdateFiatPaymentAction, } from './TransactionPayController-method-action-types.js'; export type { SubmitMoneyAccountVaultDepositRequest } from './utils/ma-vault-payout.js'; +export type { SubmitMoneyAccountVaultDepositResult } from './utils/ma-vault-deposit.js'; export type { SubmitMoneyAccountVaultWithdrawRequest } from './utils/ma-vault-withdraw.js'; export { PaymentOverride, TransactionPayStrategy } from './constants.js'; export { TransactionPayController } from './TransactionPayController.js'; diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts index 9bf58a51114..74e47de23b2 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.test.ts @@ -89,6 +89,19 @@ describe('FiatStrategy', () => { ).rejects.toThrow('Fiat: Missing transaction hash'); }); + it('returns skipped when vault deposit is disabled', async () => { + submitFiatQuotesMock.mockResolvedValue({ skipped: true }); + + const result = await new FiatStrategy().execute({ + isSmartTransaction: () => false, + quotes: [QUOTE_MOCK], + messenger: {} as TransactionPayControllerMessenger, + transaction: { txParams: { from: '0x1' } } as TransactionMeta, + }); + + expect(result).toStrictEqual({ skipped: true }); + }); + it('preserves nested Post-Ramp and Vault prefixes', async () => { submitFiatQuotesMock.mockRejectedValue( new Error('Post-Ramp: Direct mUSD: Vault: Missing transaction hash'), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts index b6444f3c286..989c87145b7 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/FiatStrategy.ts @@ -24,6 +24,10 @@ export class FiatStrategy implements PayStrategy { try { const result = await submitFiatQuotes(request); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts index e1efce2806e..68ca081c666 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-direct-musd.ts @@ -18,6 +18,7 @@ import type { import { prefixError } from '../../utils/error-prefix.js'; import { getFiatVaultDisabled } from '../../utils/feature-flags.js'; import { submitMoneyAccountVaultDeposit } from '../../utils/ma-vault-deposit.js'; +import type { SubmitMoneyAccountVaultDepositResult } from '../../utils/ma-vault-deposit.js'; import { buildCaipAssetType, getTokenInfo } from '../../utils/token.js'; import { MUSD_MONAD_FIAT_ASSET } from './constants.js'; import type { FiatQuote } from './types.js'; @@ -130,7 +131,7 @@ export async function submitDirectMusdAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const { messenger, transaction } = request; try { diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts index 2c8310931c0..cbabadb992a 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.test.ts @@ -1326,7 +1326,7 @@ describe('submitFiatQuotes', () => { ); }); - it('skips the vault batch and returns an empty hash when vaultDisabled is enabled', async () => { + it('skips the vault batch and returns skipped when vaultDisabled is enabled', async () => { const { callMock, request } = getRequest({ quotes: [ getFiatQuoteMock({ @@ -1378,7 +1378,7 @@ describe('submitFiatQuotes', () => { const result = await submitFiatQuotes(request); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalledWith( 'TransactionPayController:getAmountData', expect.anything(), diff --git a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts index 19042c2a3ee..e546e2b85a4 100644 --- a/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/fiat/fiat-submit.ts @@ -133,6 +133,10 @@ export async function submitFiatQuotes( request, }); + if (result.skipped) { + return result; + } + if (result.transactionHash === undefined) { throw new Error('Missing transaction hash'); } @@ -239,7 +243,7 @@ async function submitRelayAfterFiatCompletion({ }: { order: RampsOrder; request: PayStrategyExecuteRequest; -}): Promise<{ transactionHash?: Hex }> { +}): Promise<{ skipped?: true; transactionHash?: Hex }> { const { messenger, quotes, transaction } = request; const transactionId = transaction.id; diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index 4f70d5f702c..47adc7a8ba5 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -834,6 +834,7 @@ export type PayStrategy = { /** Execute or submit the quotes to obtain required tokens. */ execute: (request: PayStrategyExecuteRequest) => Promise<{ + skipped?: true; transactionHash?: Hex; }>; }; diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts index acbffb5e2f8..f940befa94e 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.test.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts @@ -9,13 +9,20 @@ jest.mock('./provider'); const MONEY_ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const OTHER_RECIPIENT = + '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const CHOMP_TX_HASH = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; const FROM_BLOCK = '0x100' as Hex; const SOURCE_AMOUNT_RAW = '5000000'; // 5 mUSD (6 decimals) -// uint256 hex for 5000000 (>= source amount) -const TRANSFER_DATA_SUFFICIENT = +// uint256 hex for 5000000 (exact source amount) +const TRANSFER_DATA_EXACT = '0x00000000000000000000000000000000000000000000000000000000004c4b40'; +// uint256 hex for 5000001 (above source amount) +const TRANSFER_DATA_ABOVE = + '0x00000000000000000000000000000000000000000000000000000000004c4b41'; // uint256 hex for 4999999 (< source amount) const TRANSFER_DATA_INSUFFICIENT = '0x00000000000000000000000000000000000000000000000000000000004c4b3f'; @@ -28,11 +35,17 @@ function padAddress(address: string): string { } const MONEY_ACCOUNT_PADDED = padAddress(MONEY_ACCOUNT_ADDRESS); - -function buildMusdTransferLog( - txHash: Hex = CHOMP_TX_HASH, - data: string = TRANSFER_DATA_SUFFICIENT, -): { +const BORING_VAULT_PADDED = padAddress(BORING_VAULT_ADDRESS); + +function buildMusdTransferLog({ + txHash = CHOMP_TX_HASH, + data = TRANSFER_DATA_EXACT, + to = BORING_VAULT_ADDRESS, +}: { + txHash?: Hex; + data?: string; + to?: Hex; +} = {}): { address: string; topics: string[]; data: string; @@ -44,7 +57,7 @@ function buildMusdTransferLog( topics: [ ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, - padAddress('0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'), + padAddress(to), ], transactionHash: txHash, }; @@ -62,7 +75,7 @@ describe('chomp', () => { }); describe('findRecentChompVaultDeposit', () => { - it('returns the CHOMP tx hash when a Transfer log with sufficient amount is found', async () => { + it('returns the CHOMP tx hash when Transfer is to the vault with exact amount', async () => { rpcRequestMock.mockResolvedValueOnce([buildMusdTransferLog()]); const result = await findRecentChompVaultDeposit({ @@ -70,16 +83,50 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(CHOMP_TX_HASH); - // Only eth_getLogs should have been called. + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when Transfer is not to the vault', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ to: OTHER_RECIPIENT }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); + expect(rpcRequestMock).toHaveBeenCalledTimes(1); + }); + + it('returns undefined when the transfer amount does not exactly match', async () => { + rpcRequestMock.mockResolvedValueOnce([ + buildMusdTransferLog({ data: TRANSFER_DATA_ABOVE }), + ]); + + const result = await findRecentChompVaultDeposit({ + fromBlock: FROM_BLOCK, + messenger: buildMessenger(), + moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, + sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, + }); + + expect(result).toBeUndefined(); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); it('returns undefined when the mUSD transfer amount is below the required amount', async () => { rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(CHOMP_TX_HASH, TRANSFER_DATA_INSUFFICIENT), + buildMusdTransferLog({ data: TRANSFER_DATA_INSUFFICIENT }), ]); const result = await findRecentChompVaultDeposit({ @@ -87,6 +134,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); @@ -101,13 +149,14 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('queries eth_getLogs with the correct filter', async () => { + it('queries eth_getLogs filtered to transfers from the Money Account to the vault', async () => { rpcRequestMock.mockResolvedValueOnce([]); await findRecentChompVaultDeposit({ @@ -115,6 +164,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(rpcRequestMock).toHaveBeenCalledWith( @@ -126,22 +176,26 @@ describe('chomp', () => { address: MUSD_MONAD_ADDRESS, fromBlock: FROM_BLOCK, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, null], + topics: [ + ERC20_TRANSFER_TOPIC, + MONEY_ACCOUNT_PADDED, + BORING_VAULT_PADDED, + ], }), ], }), ); }); - it('processes logs newest-first and returns the most recent match', async () => { + it('processes logs newest-first and returns the most recent exact vault match', async () => { const olderHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; const newerHash = '0x0000000000000000000000000000000000000000000000000000000000000002' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(olderHash), - buildMusdTransferLog(newerHash), + buildMusdTransferLog({ txHash: olderHash }), + buildMusdTransferLog({ txHash: newerHash }), ]); const result = await findRecentChompVaultDeposit({ @@ -149,19 +203,23 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBe(newerHash); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); - it('skips logs with insufficient amount and returns the first sufficient one', async () => { - const insufficientHash = + it('skips amount mismatches and returns the first exact vault match', async () => { + const mismatchedHash = '0x0000000000000000000000000000000000000000000000000000000000000001' as Hex; rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(insufficientHash, TRANSFER_DATA_INSUFFICIENT), - buildMusdTransferLog(CHOMP_TX_HASH), + buildMusdTransferLog({ + txHash: mismatchedHash, + data: TRANSFER_DATA_INSUFFICIENT, + }), + buildMusdTransferLog(), ]); const result = await findRecentChompVaultDeposit({ @@ -169,16 +227,16 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); - // Logs reversed: CHOMP_TX_HASH checked first (newer), passes amount check. expect(result).toBe(CHOMP_TX_HASH); expect(rpcRequestMock).toHaveBeenCalledTimes(1); }); it('treats a log with data "0x" as zero amount and skips it', async () => { rpcRequestMock.mockResolvedValueOnce([ - buildMusdTransferLog(CHOMP_TX_HASH, '0x'), + buildMusdTransferLog({ data: '0x' }), ]); const result = await findRecentChompVaultDeposit({ @@ -186,6 +244,7 @@ describe('chomp', () => { messenger: buildMessenger(), moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, sourceAmountRaw: SOURCE_AMOUNT_RAW, + vaultAddress: BORING_VAULT_ADDRESS, }); expect(result).toBeUndefined(); diff --git a/packages/transaction-pay-controller/src/utils/chomp.ts b/packages/transaction-pay-controller/src/utils/chomp.ts index 2c2cc466276..073ea4b3492 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.ts @@ -19,18 +19,34 @@ type RpcLog = { transactionHash: Hex; }; +/** + * Finds a recent mUSD Transfer from the Money Account into the boring vault + * whose amount exactly matches `sourceAmountRaw`. Exact amount + vault `to` + * avoid treating Pix/other outbound transfers as CHOMP vault success. + * + * @param options - Scan options. + * @param options.messenger - Controller messenger for RPC. + * @param options.moneyAccountAddress - Money Account that sent the transfer. + * @param options.sourceAmountRaw - Exact raw mUSD amount expected. + * @param options.fromBlock - Inclusive block to start the log scan. + * @param options.vaultAddress - Boring vault address that must be the Transfer `to`. + * @returns Matching transaction hash, if any. + */ export async function findRecentChompVaultDeposit({ messenger, moneyAccountAddress, sourceAmountRaw, fromBlock, + vaultAddress, }: { messenger: TransactionPayControllerMessenger; moneyAccountAddress: Hex; sourceAmountRaw: string; fromBlock: Hex; + vaultAddress: Hex; }): Promise { const fromPadded = padAddress(moneyAccountAddress); + const toPadded = padAddress(vaultAddress); const logs = await rpcRequest({ messenger, @@ -41,7 +57,7 @@ export async function findRecentChompVaultDeposit({ address: MUSD_MONAD_ADDRESS, fromBlock, toBlock: 'latest', - topics: [ERC20_TRANSFER_TOPIC, fromPadded, null], + topics: [ERC20_TRANSFER_TOPIC, fromPadded, toPadded], }, ], }); @@ -50,16 +66,30 @@ export async function findRecentChompVaultDeposit({ count: logs.length, fromBlock, moneyAccountAddress, + vaultAddress, }); const requiredAmount = BigInt(sourceAmountRaw); + const vaultTopic = toPadded.toLowerCase(); // Examine newest logs first so we return the most recent CHOMP match. for (const txLog of [...logs].reverse()) { + const logTo = txLog.topics[2]?.toLowerCase(); + if (logTo !== vaultTopic) { + log('CHOMP scan: skipping log - transfer is not to the vault', { + expectedTo: vaultAddress, + logTo, + txHash: txLog.transactionHash, + }); + continue; + } + const transferAmount = BigInt(txLog.data === '0x' ? '0x0' : txLog.data); - if (transferAmount < requiredAmount) { - log('CHOMP scan: skipping log — transfer amount below required', { + // Exact amount only: >= would falsely treat larger outbound transfers + // (e.g. Pix) as vault deposits when `to` filtering alone is insufficient. + if (transferAmount !== requiredAmount) { + log('CHOMP scan: skipping log - transfer amount is not an exact match', { requiredAmount: requiredAmount.toString(), transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, @@ -72,12 +102,17 @@ export async function findRecentChompVaultDeposit({ sourceAmountRaw, transferAmount: transferAmount.toString(), txHash: txLog.transactionHash, + vaultAddress, }); return txLog.transactionHash; } - log('CHOMP scan: no match found', { fromBlock, moneyAccountAddress }); + log('CHOMP scan: no match found', { + fromBlock, + moneyAccountAddress, + vaultAddress, + }); return undefined; } diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts index 337cb27ba8d..d167538513e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.test.ts @@ -11,6 +11,7 @@ import { submitMoneyAccountVaultDeposit, submitMoneyAccountVaultDepositBatch, } from './ma-vault-deposit.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -20,12 +21,15 @@ import { } from './transaction.js'; jest.mock('./chomp'); +jest.mock('./money-account-vault-config'); jest.mock('./provider'); jest.mock('./transaction'); const TRANSACTION_ID_MOCK = 'tx-id'; const MONEY_ACCOUNT_ADDRESS_MOCK = '0x1111111111111111111111111111111111111111' as Hex; +const BORING_VAULT_ADDRESS_MOCK = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; const NETWORK_CLIENT_ID_MOCK = 'network-client-id-mock'; const TRANSACTION_MOCK = { @@ -75,6 +79,9 @@ function callSubmit({ describe('submitMoneyAccountVaultDeposit', () => { const collectTransactionIdsMock = jest.mocked(collectTransactionIds); + const getMoneyAccountVaultConfigMock = jest.mocked( + getMoneyAccountVaultConfig, + ); const getNetworkClientIdMock = jest.mocked(getNetworkClientId); const getTransactionMock = jest.mocked(getTransaction); const updateTransactionMock = jest.mocked(updateTransaction); @@ -85,6 +92,13 @@ describe('submitMoneyAccountVaultDeposit', () => { beforeEach(() => { jest.resetAllMocks(); + getMoneyAccountVaultConfigMock.mockReturnValue({ + accountantAddress: '0x2222222222222222222222222222222222222222' as Hex, + boringVault: BORING_VAULT_ADDRESS_MOCK, + chainId: '0x8f' as Hex, + lensAddress: '0x3333333333333333333333333333333333333333' as Hex, + tellerAddress: '0x4444444444444444444444444444444444444444' as Hex, + }); getNetworkClientIdMock.mockReturnValue(NETWORK_CLIENT_ID_MOCK); collectTransactionIdsMock.mockImplementation( (_chainId, _from, _messenger, onTransaction) => { @@ -273,7 +287,7 @@ describe('submitMoneyAccountVaultDeposit', () => { const result = await callSubmit({ callMock, vaultDisabled: true }); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(callMock).not.toHaveBeenCalled(); expect(updateTransactionMock).not.toHaveBeenCalled(); expect(collectTransactionIdsMock).not.toHaveBeenCalled(); @@ -554,7 +568,7 @@ describe('submitMoneyAccountVaultDeposit', () => { }); expect(callMock).not.toHaveBeenCalled(); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); }); }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts index 84d52d00bbc..7f46689106e 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-deposit.ts @@ -13,6 +13,7 @@ import { MUSD_MONAD_FIAT_ASSET } from '../strategy/fiat/constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { findRecentChompVaultDeposit } from './chomp.js'; import { prefixError } from './error-prefix.js'; +import { getMoneyAccountVaultConfig } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; import { collectTransactionIds, @@ -25,6 +26,11 @@ const log = createModuleLogger(projectLogger, 'ma-vault-deposit'); export const VAULT_ERROR_PREFIX = 'Vault: '; +export type SubmitMoneyAccountVaultDepositResult = { + skipped?: true; + transactionHash?: Hex; +}; + /** * Submits a Money Account mUSD vault deposit batch on Monad once the source * mUSD has settled in the Money Account (fiat on-ramp, Relay bridge, or any @@ -47,7 +53,8 @@ export const VAULT_ERROR_PREFIX = 'Vault: '; * @param options.transaction - Original Money Account transaction meta. * @param options.vaultDisabled - When `true`, skip the vault batch and leave * the settled mUSD in the Money Account. Caller-evaluated kill-switch. - * @returns Hash of the final submitted child transaction, if available. + * @returns Hash of the final submitted child transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDeposit({ fromBlock, @@ -65,7 +72,7 @@ export async function submitMoneyAccountVaultDeposit({ sourceAmountRaw: string; transaction: TransactionMeta; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { const transactionId = transaction.id; const moneyAccountAddress = (moneyAccountAddressOverride ?? transaction.txParams.from) as Hex | undefined; @@ -81,7 +88,7 @@ export async function submitMoneyAccountVaultDeposit({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } const nestedTransactions = await resolveVaultDepositBatch({ @@ -116,7 +123,8 @@ export async function submitMoneyAccountVaultDeposit({ * @param options.sourceAmountRaw - Raw mUSD amount to deposit. * @param options.transactionId - Optional parent transaction to link children. * @param options.vaultDisabled - Whether vault submission is disabled. - * @returns Hash of the final confirmed vault transaction. + * @returns Hash of the final confirmed vault transaction, or `{ skipped: true }` + * when vaulting is disabled. */ export async function submitMoneyAccountVaultDepositBatch({ depositCalls, @@ -134,7 +142,7 @@ export async function submitMoneyAccountVaultDepositBatch({ sourceAmountRaw: string; transactionId?: string; vaultDisabled: boolean; -}): Promise<{ transactionHash?: Hex }> { +}): Promise { if (vaultDisabled) { log('Skipping vault deposit because vaultDisabled is true', { moneyAccountAddress, @@ -142,7 +150,7 @@ export async function submitMoneyAccountVaultDepositBatch({ transactionId, }); - return { transactionHash: '0x' }; + return { skipped: true }; } // CHOMP pre-check: skip addTransactionBatch entirely if CHOMP has already @@ -378,11 +386,13 @@ async function tryFindChompDeposit({ } try { + const { boringVault } = getMoneyAccountVaultConfig(messenger); return await findRecentChompVaultDeposit({ fromBlock, messenger, moneyAccountAddress, sourceAmountRaw, + vaultAddress: boringVault, }); } catch (chompError) { log('CHOMP check failed', { chompError, transactionId }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts index 4c4cb468068..ca8098195ef 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.test.ts @@ -176,7 +176,7 @@ describe('submitMoneyAccountVaultDepositFromPayout', () => { getMessenger(), ); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); }); @@ -192,7 +192,7 @@ describe('submitMoneyAccountVaultDepositFromPayout', () => { getMessenger(), ); - expect(result).toStrictEqual({ transactionHash: '0x' }); + expect(result).toStrictEqual({ skipped: true }); expect(getTransferredAmountFromTxHashMock).not.toHaveBeenCalled(); }); }); diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts index d181f057dae..1c55b0948d2 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-payout.ts @@ -4,6 +4,7 @@ import type { Hex } from '@metamask/utils'; import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; +import type { SubmitMoneyAccountVaultDepositResult } from './ma-vault-deposit.js'; import { submitMoneyAccountVaultDepositBatch } from './ma-vault-deposit.js'; import { getMoneyAccountVaultConfig, @@ -23,12 +24,13 @@ export type SubmitMoneyAccountVaultDepositRequest = { * * @param request - Iron payout details. * @param messenger - Transaction Pay controller messenger. - * @returns Hash of the confirmed vault transaction, or `0x` when disabled. + * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` when + * vaulting is disabled. */ export async function submitMoneyAccountVaultDepositFromPayout( request: SubmitMoneyAccountVaultDepositRequest, messenger: TransactionPayControllerMessenger, -): Promise<{ transactionHash?: Hex }> { +): Promise { const { moneyAccountAddress, transactionHash, @@ -39,7 +41,7 @@ export async function submitMoneyAccountVaultDepositFromPayout( vaultDisabled || !isMoneyAccountVaultActionEnabled(messenger, 'deposit') ) { - return { transactionHash: '0x' }; + return { skipped: true }; } const { amountRaw, blockNumber } = await getTransferredAmountFromTxHash({ diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts index a2bdd242e5b..fb732dcc2eb 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.test.ts @@ -33,14 +33,9 @@ function getRequest( ): SubmitMoneyAccountVaultWithdrawRequest { return { amountInRaw: '5000000', - autorampId: 'autoramp-id', - chainId: CHAIN_ID_MONAD, moneyAccountAddress: MONEY_ACCOUNT_ADDRESS, - quoteId: 'quote-id', - quoteValidUntil: new Date(Date.now() + 60_000).toISOString(), recipient: IRON_ADDRESS, requestId: 'request-id', - tokenAddress: MUSD_MONAD_ADDRESS, ...overrides, }; } @@ -181,25 +176,13 @@ describe('submitMoneyAccountVaultWithdraw', () => { [{ amountInRaw: '0' }, 'Withdrawal amount must be greater than zero'], [{ amountInRaw: '-1' }, 'Withdrawal amount must be greater than zero'], [{ amountInRaw: 'invalid' }, 'Withdrawal amount must be greater than zero'], - [{ quoteValidUntil: 'invalid' }, 'Iron quote expiry is invalid'], - [ - { quoteValidUntil: new Date(Date.now() - 1_000).toISOString() }, - 'Iron quote has expired', - ], - [{ chainId: '0x1' }, 'Pix withdrawal must use Monad'], - [ - { tokenAddress: '0x7777777777777777777777777777777777777777' }, - 'Pix withdrawal must use mUSD', - ], [{ recipient: '0x1234' }, 'Iron recipient is invalid'], [ { recipient: MONEY_ACCOUNT_ADDRESS }, 'Iron recipient must differ from the Money Account', ], - [{ requestId: '' }, 'Missing Iron request identifiers'], - [{ quoteId: '' }, 'Missing Iron request identifiers'], - [{ autorampId: '' }, 'Missing Iron request identifiers'], - ])('rejects invalid exact-out input %#', async (overrides, message) => { + [{ requestId: '' }, 'Missing withdraw request id'], + ])('rejects invalid withdraw input %#', async (overrides, message) => { await expect( submitMoneyAccountVaultWithdraw( getRequest(overrides), diff --git a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts index bedf2a6a366..7beb1bb9f6f 100644 --- a/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts +++ b/packages/transaction-pay-controller/src/utils/ma-vault-withdraw.ts @@ -5,7 +5,7 @@ import type { TransactionBatchResult } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { isValidHexAddress } from '@metamask/utils'; -import { CHAIN_ID_MONAD, MUSD_MONAD_ADDRESS } from '../constants.js'; +import { CHAIN_ID_MONAD } from '../constants.js'; import type { TransactionPayControllerMessenger } from '../types.js'; import { getMoneyAccountVaultConfig, @@ -13,22 +13,21 @@ import { } from './money-account-vault-config.js'; import { getNetworkClientId } from './provider.js'; +/** + * On-chain withdraw intent. Quote / Pix / Iron identifiers stay outside Core; + * Monad and mUSD are fixed by the Money Account vault config constants. + */ export type SubmitMoneyAccountVaultWithdrawRequest = { amountInRaw: string; - autorampId: string; - chainId: Hex; moneyAccountAddress: Hex; - quoteId: string; - quoteValidUntil: string; recipient: Hex; requestId: string; - tokenAddress: Hex; }; /** * Creates a user-confirmed atomic vmUSD withdrawal and mUSD transfer to Iron. * - * @param request - Backend-bound exact-out Iron intent. + * @param request - Exact-out withdraw intent. * @param messenger - Transaction Pay controller messenger. * @returns The pending transaction batch ID. */ @@ -89,8 +88,8 @@ export async function submitMoneyAccountVaultWithdraw( function validateRequest( request: SubmitMoneyAccountVaultWithdrawRequest, ): void { - if (!request.requestId || !request.quoteId || !request.autorampId) { - throw new Error('Missing Iron request identifiers'); + if (!request.requestId) { + throw new Error('Missing withdraw request id'); } let amount: bigint; @@ -104,20 +103,6 @@ function validateRequest( throw new Error('Withdrawal amount must be greater than zero'); } - const expiry = Date.parse(request.quoteValidUntil); - if (Number.isNaN(expiry)) { - throw new Error('Iron quote expiry is invalid'); - } - if (expiry <= Date.now()) { - throw new Error('Iron quote has expired'); - } - - if (request.chainId !== CHAIN_ID_MONAD) { - throw new Error('Pix withdrawal must use Monad'); - } - if (request.tokenAddress.toLowerCase() !== MUSD_MONAD_ADDRESS.toLowerCase()) { - throw new Error('Pix withdrawal must use mUSD'); - } if (!isValidHexAddress(request.recipient)) { throw new Error('Iron recipient is invalid'); } From 8e7e0ac758b149ffd17f5340f465f7817fcec538 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:39:45 -0600 Subject: [PATCH 52/67] feat(kyc-controller): register Money Account wallets Add an address-only KycController action that resolves the MoonPay customer, signs a Monad ownership proof, and registers the wallet through the MetaMask proxy. --- packages/kyc-controller/CHANGELOG.md | 4 +- packages/kyc-controller/package.json | 1 + .../src/KycController-method-action-types.ts | 19 + .../kyc-controller/src/KycController.test.ts | 223 ++++++++ packages/kyc-controller/src/KycController.ts | 190 ++++++- .../src/KycService-method-action-types.ts | 37 ++ .../kyc-controller/src/KycService.test.ts | 55 ++ packages/kyc-controller/src/KycService.ts | 99 +++- packages/kyc-controller/src/index.test.ts | 1 + packages/kyc-controller/src/index.ts | 12 + .../src/ownership-message.test.ts | 66 +++ .../kyc-controller/src/ownership-message.ts | 32 ++ .../src/wallet-registration-machine.test.ts | 297 ++++++++++ .../src/wallet-registration-machine.ts | 221 ++++++++ .../src/wallet-registration-service.test.ts | 530 ++++++++++++++++++ .../src/wallet-registration-service.ts | 372 ++++++++++++ yarn.lock | 109 ++-- 17 files changed, 2205 insertions(+), 63 deletions(-) create mode 100644 packages/kyc-controller/src/ownership-message.test.ts create mode 100644 packages/kyc-controller/src/ownership-message.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.ts diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 71dc41e7c9f..a6c6828a594 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,7 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) -- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) +- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 5b0c23f7fff..22967e56612 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -61,6 +61,7 @@ "@metamask/base-data-service": "^0.1.3", "@metamask/controller-utils": "^12.3.0", "@metamask/geolocation-controller": "^1.0.0", + "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/superstruct": "^3.4.1", diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 2586ea9c637..7f27b8c202e 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -167,6 +167,24 @@ export type KycControllerGetSessionStatusAction = { handler: KycController['getSessionStatus']; }; +/** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type KycControllerRegisterMoneyAccountWalletAction = { + type: `KycController:registerMoneyAccountWallet`; + handler: KycController['registerMoneyAccountWallet']; +}; + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. @@ -192,4 +210,5 @@ export type KycControllerMethodActions = | KycControllerGetKycStatusAction | KycControllerStartSumSubAction | KycControllerGetSessionStatusAction + | KycControllerRegisterMoneyAccountWalletAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3c3078c27e3..2f2fbaf2458 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -15,6 +15,7 @@ import type { KycControllerMessenger } from './KycController.js'; import type { KycSumSubLauncher } from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of @@ -1740,6 +1741,189 @@ describe('KycController', () => { }); }); + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('prefers the customer id captured from the MoonPay frame', async () => { + await withController( + { options: { state: { moonpayCustomerId: 'frame-customer' } } }, + async ({ controller, handlers }) => { + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer frame-customer.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'frame-customer', + signature: '0xsig', + }), + ); + }, + ); + }); + + it('falls back to resolving the customer id from the proxy', async () => { + await withController(async ({ controller, handlers }) => { + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: 'iron-customer-fallback', + }), + ); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, handlers }) => { + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows an initial lookup failure without signing', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('lookup failed'); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + describe('reset', () => { it('clears session state but preserves persisted terms', async () => { await withController( @@ -1796,6 +1980,10 @@ type ServiceHandlers = { createUkycSession: jest.Mock; createJourney: jest.Mock; getSessionStatus: jest.Mock; + getMoonpayCustomerId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; performGetStorage: jest.Mock; performSetStorage: jest.Mock; }; @@ -1826,6 +2014,10 @@ const SERVICE_ACTIONS = [ 'KycService:createUkycSession', 'KycService:createJourney', 'KycService:getSessionStatus', + 'KycService:getMoonpayCustomerId', + 'KycService:getWalletRegistrationStatus', + 'KycService:registerSelfHostedWallet', + 'KeyringController:signPersonalMessage', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; @@ -1901,6 +2093,21 @@ function withController( .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), + getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), performGetStorage: jest.fn().mockResolvedValue(null), performSetStorage: jest.fn().mockResolvedValue(undefined), }; @@ -1940,6 +2147,22 @@ function withController( 'KycService:getSessionStatus', handlers.getSessionStatus, ); + rootMessenger.registerActionHandler( + 'KycService:getMoonpayCustomerId', + handlers.getMoonpayCustomerId, + ); + rootMessenger.registerActionHandler( + 'KycService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'KycService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); rootMessenger.registerActionHandler( 'UserStorageController:performGetStorage', handlers.performGetStorage, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 3abb1eb9dcf..39a9e9e7acf 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -4,18 +4,21 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { UserStorageControllerPerformGetStorageAction, UserStorageControllerPerformSetStorageAction, } from '@metamask/profile-sync-controller/user-storage'; -import type { Json } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import { buildOwnershipMessage } from './ownership-message.js'; import type { KycDisclaimer, KycPhase, @@ -25,12 +28,20 @@ import type { KycSumSubStatus, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { + createInitialState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -320,6 +331,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getKycStatus', 'startSumSub', 'getSessionStatus', + 'registerMoneyAccountWallet', 'reset', ] as const; @@ -334,6 +346,7 @@ export type KycControllerActions = type AllowedActions = | KycServiceMethodActions + | KeyringControllerSignPersonalMessageAction | UserStorageControllerPerformGetStorageAction | UserStorageControllerPerformSetStorageAction; @@ -371,6 +384,16 @@ export type KycControllerOptions = { sessionStatusPollIntervalMs?: number; }; +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + /** * The shape of a message posted by a Check/Auth frame. */ @@ -1388,6 +1411,169 @@ export class KycController extends BaseController< } } + /** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: Hex; + }): Promise { + let machine = transitionWalletRegistration(createInitialState(), { + type: 'START', + }); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'KycService:getWalletRegistrationStatus', + { address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'KycService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 2d642d9ab27..344ae092e5c 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -17,6 +17,40 @@ export type KycServiceGetGeoCountryAction = { handler: KycService['getGeoCountry']; }; +/** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ +export type KycServiceGetMoonpayCustomerIdAction = { + type: `KycService:getMoonpayCustomerId`; + handler: KycService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type KycServiceGetWalletRegistrationStatusAction = { + type: `KycService:getWalletRegistrationStatus`; + handler: KycService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type KycServiceRegisterSelfHostedWalletAction = { + type: `KycService:registerSelfHostedWallet`; + handler: KycService['registerSelfHostedWallet']; +}; + /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -130,6 +164,9 @@ export type KycServiceGetSessionStatusAction = { */ export type KycServiceMethodActions = | KycServiceGetGeoCountryAction + | KycServiceGetMoonpayCustomerIdAction + | KycServiceGetWalletRegistrationStatusAction + | KycServiceRegisterSelfHostedWalletAction | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 2b3e57e86a0..b537d96fa0a 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -59,6 +59,61 @@ describe('KycService', () => { }); }); + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/customer') + .matchHeader('authorization', 'Bearer test-bearer') + .reply(200, { customerId: 'iron-customer-1' }); + + const { service } = getService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/self-hosted-wallets') + .reply(200, []); + + const { service } = getService(); + + expect( + await service.getWalletRegistrationStatus({ address: '0xabc' }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/self-hosted-wallets', { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const { service } = getService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + }); + describe('fetchDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 460105d4d4a..1674a0d3cbf 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -29,6 +29,11 @@ import type { KycDisclaimer, KycSessionStatus } from './types.js'; import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; // === GENERAL === @@ -49,6 +54,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'createUkycSession', 'createJourney', 'getSessionStatus', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', ] as const; /** @@ -74,8 +82,9 @@ type AllowedActions = /** * Published when {@link KycService}'s cache is updated. */ -export type KycServiceCacheUpdatedEvent = - DataServiceCacheUpdatedEvent; +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; /** * Published when a single key within {@link KycService}'s cache is updated. @@ -247,6 +256,13 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; +}; + // === SERVICE DEFINITION === /** @@ -272,6 +288,8 @@ export class KycService extends BaseDataService< readonly #fractalEncryptionBaseUrl: string; + readonly #walletRegistrationService: WalletRegistrationService; + /** * Constructs a new KycService. * @@ -306,6 +324,11 @@ export class KycService extends BaseDataService< } this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; + this.#walletRegistrationService = new WalletRegistrationService({ + fetch: fetchFunction, + baseUrl, + getAuthToken: async (): Promise => this.#getBearerToken(), + }); this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -345,6 +368,49 @@ export class KycService extends BaseDataService< return alpha3; } + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#walletRegistrationService.getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + address, + }: { + address: string; + }): Promise { + return await this.#walletRegistrationService.getRegistrationStatus({ + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#walletRegistrationService.registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -644,6 +710,24 @@ export class KycService extends BaseDataService< } } + /** + * Gets the authenticated wallet bearer token. + * + * @returns The bearer token. + */ + async #getBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token - is the wallet signed in?', + ); + } + return bearerToken; + } + /** * Performs a single JSON request. * @@ -676,16 +760,7 @@ export class KycService extends BaseDataService< } if (authenticated) { - const bearerToken = await this.messenger.call( - 'AuthenticationController:getBearerToken', - ); - assert(bearerToken, string()); - if (!bearerToken) { - throw new Error( - 'Unable to obtain an authentication bearer token — is the wallet signed in?', - ); - } - headers.Authorization = `Bearer ${bearerToken}`; + headers.Authorization = `Bearer ${await this.#getBearerToken()}`; } const response = await this.#fetch(url.toString(), { diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index f986f8847a4..3bbd3b4c258 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -12,6 +12,7 @@ describe('@metamask/kyc-controller', () => { alpha2ToAlpha3: expect.any(Function), generateKeyPair: expect.any(Function), decryptCredentials: expect.any(Function), + WalletRegistrationError: expect.any(Function), controllerName: 'KycController', serviceName: 'KycService', }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 20830da2c55..f0deafb95b0 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -8,6 +8,7 @@ export type { KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, + MoneyAccountWalletRegistrationResult, KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, @@ -25,6 +26,7 @@ export type { KycControllerInitializeAction, KycControllerLoadDisclaimersAction, KycControllerResetAction, + KycControllerRegisterMoneyAccountWalletAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -44,6 +46,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + RegisterSelfHostedWalletParams, UkycSessionResponse, WrappedEncryptionKey, WrappingKeyResponse, @@ -58,6 +61,9 @@ export type { KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, + KycServiceGetMoonpayCustomerIdAction, + KycServiceGetWalletRegistrationStatusAction, + KycServiceRegisterSelfHostedWalletAction, } from './KycService-method-action-types.js'; export { @@ -119,3 +125,9 @@ export type { MintedUkycTestToken, MintUkycTestTokenParams, } from './ukyc/testToken.js'; + +export type { + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/kyc-controller/src/ownership-message.test.ts new file mode 100644 index 00000000000..071144a4642 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/kyc-controller/src/ownership-message.ts new file mode 100644 index 00000000000..539d5e3aac3 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/kyc-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 00000000000..de58a81ef12 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/kyc-controller/src/wallet-registration-machine.ts new file mode 100644 index 00000000000..3251c56d02e --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts new file mode 100644 index 00000000000..a8ee7540d23 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -0,0 +1,530 @@ +import { + WalletRegistrationError, + WalletRegistrationService, +} from './wallet-registration-service.js'; + +const BASE_URL = 'https://proxy.metamask.test'; +const AUTH_TOKEN = 'session-jwt-abc'; + +type FetchInit = { + method?: string; + headers: Record; + body?: string; +}; + +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: unknown; + }, +) => Promise; + +const jsonResponse = (status: number, body: unknown): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => body, + text: async (): Promise => JSON.stringify(body), +}); + +const textResponse = (status: number, body: string): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => JSON.parse(body), + text: async (): Promise => body, +}); + +const invalidJsonResponse = (status: number): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error('invalid json'); + }, + text: async (): Promise => 'not json', +}); + +const buildService = (fetchImpl: FetchLike): WalletRegistrationService => + new WalletRegistrationService({ + fetch: fetchImpl, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + }); + +const verifiedAddress = ( + overrides: Record = {}, +): Record => ({ + id: 'addr-1', + wallet_address: '0xAbC0000000000000000000000000000000000001', + blockchain: 'Monad', + address_type: 'SelfHosted', + disabled: false, + is_self: true, + proof_message: 'I am verifying ownership...', + proof_signature: '0xsig', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; + +describe('WalletRegistrationService.getMoonpayCustomerId', () => { + it('returns the Iron customer id from the authenticated proxy lookup', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { customerId: 'iron-customer-1' }), + ); + + expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( + 'iron-customer-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/vendors/moonpay/customer`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${AUTH_TOKEN}`, + }), + }), + ); + }); + + it('maps a failed customer lookup to a typed HTTP error', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(404, { code: 'iron_error', message: 'not found' }), + ); + + await expect( + buildService(fetchMock).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + }); + + it('rejects malformed customer lookup responses', async () => { + await expect( + buildService( + jest.fn(async (): Promise => invalidJsonResponse(200)), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + + await expect( + buildService( + jest.fn(async (): Promise => jsonResponse(200, {})), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); + +describe('WalletRegistrationService.getRegistrationStatus', () => { + it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, []), + ); + const service = buildService(fetchMock); + + await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('GET'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + }); + + it('returns an active match parsed from wallet_address', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, [verifiedAddress()]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: '0xabc0000000000000000000000000000000000001', + blockchain: 'Monad', + }); + + expect(status).toMatchObject({ + type: 'active', + registration: { address: EVM_ADDRESS, disabled: false }, + }); + }); + + it('returns a disabled result when the matching address is disabled', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ disabled: true })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('disabled'); + }); + + it('scopes matching per blockchain (same address, different chain is absent)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('absent'); + }); + + it('skips entries whose wallet_address is not a string', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + { id: 'junk', wallet_address: 12345, blockchain: 'Monad' }, + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('active'); + }); + + it('throws malformedResponse when the list body is not valid JSON', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('throws a lookupUnavailable error on a non-2xx list response', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'boom'), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('throws a lookupUnavailable error when the list body is malformed', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toBeInstanceOf(WalletRegistrationError); + }); + + it('never converts a network failure during lookup into "absent"', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('network down')); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('handles a non-Error thrown during lookup', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('string failure'); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); +}); + +const registerRequest = { + customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + address: EVM_ADDRESS, + blockchain: 'Monad' as const, + message: 'I am verifying ownership ...', + signature: '0xdeadbeef', +}; + +const selfHostedResponse = ( + overrides: Record = {}, +): Record => ({ + id: 'wallet-1', + address: EVM_ADDRESS, + customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + disabled: false, + signature: '0xdeadbeef', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const errorEnvelope = (status: number, message: string): HttpResponse => + jsonResponse(status, { + code: 'iron_error', + message, + }); + +describe('WalletRegistrationService.registerSelfHostedWallet', () => { + it('sends the five contract fields via POST and returns registered on 200', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + const outcome = await service.registerSelfHostedWallet(registerRequest); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ + customer_id: registerRequest.customerId, + address: registerRequest.address, + blockchain: 'Monad', + message: registerRequest.message, + signature: registerRequest.signature, + }); + expect(outcome.registration).toMatchObject({ + id: 'wallet-1', + address: registerRequest.address, + disabled: false, + }); + }); + + it('does not send an idempotency key (the backend derives it)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + await service.registerSelfHostedWallet(registerRequest); + + const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + const headerKeys = Object.keys(init.headers).map((key) => + key.toLowerCase(), + ); + expect(headerKeys).not.toContain('idempotency-key'); + expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + }); + + it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + const fetchMock = jest.fn( + async (): Promise => + errorEnvelope( + 409, + 'A crypto address with this wallet address already exists', + ), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'conflict', + httpStatus: 409, + body: 'A crypto address with this wallet address already exists', + }); + }); + + it('maps 5xx to a transient error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(500, 'internal error'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 }); + }); + + it('maps a network failure / timeout to a transient error', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('ETIMEDOUT')); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps a non-Error thrown during registration to transient', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('socket hang up'); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps 400 to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(400, 'bad message'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 }); + }); + + it('maps an unmapped 4xx (422) to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(422, 'unprocessable'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 }); + }); + + it('maps 401 to unauthorized', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(401, 'session expired'), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'unauthorized' }); + }); + + it('maps 403 to forbidden and 404 to notFound', async () => { + const forbiddenFetch = jest.fn( + async (): Promise => errorEnvelope(403, 'suspended'), + ); + const notFoundFetch = jest.fn( + async (): Promise => errorEnvelope(404, 'not found'), + ); + + const forbidden = await buildService(forbiddenFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + const notFound = await buildService(notFoundFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + + expect(forbidden).toMatchObject({ kind: 'forbidden' }); + expect(notFound).toMatchObject({ kind: 'notFound' }); + }); + + it('maps an error envelope without a code', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(403, { message: 'forbidden' }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); + + it('maps 429 to a rateLimited error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(429, 'slow down'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'rateLimited' }); + }); + + it('maps a malformed 200 body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects a success body that has an id but no address', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { id: 'wallet-1', disabled: false }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON success body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(400), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts new file mode 100644 index 00000000000..08d118d3b8c --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -0,0 +1,372 @@ +/** The only blockchain supported by the Money Account POC. */ +export type Blockchain = 'Monad'; + +/** Normalized view of a single registered self-hosted address. */ +export type SelfHostedRegistration = { + id: string; + address: string; + blockchain: Blockchain; + disabled: boolean; + isSelf: boolean; +}; + +/** Result of reconciling a wallet against the customer's registered addresses. */ +export type RegistrationStatus = + | { type: 'active'; registration: SelfHostedRegistration } + | { type: 'disabled'; registration: SelfHostedRegistration } + | { type: 'absent' }; + +/** + * Discriminated error kinds surfaced to the state machine. Every non-success + * path maps to exactly one of these so the machine can decide deterministically. + */ +export type WalletRegistrationErrorKind = + | 'validation' + | 'unauthorized' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'rateLimited' + | 'transient' + | 'lookupUnavailable' + | 'malformedResponse'; + +/** Minimal HTTP response shape, so the service is environment-agnostic. */ +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +/** Minimal `fetch`-like function the service depends on. */ +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise; + +/** Typed error carrying enough context for state transitions. */ +export class WalletRegistrationError extends Error { + readonly kind: WalletRegistrationErrorKind; + + readonly httpStatus?: number; + + readonly body?: string; + + constructor( + kind: WalletRegistrationErrorKind, + options: { + message?: string; + httpStatus?: number; + body?: string; + }, + ) { + super(options.message ?? `wallet registration failed: ${kind}`); + this.name = 'WalletRegistrationError'; + this.kind = kind; + this.httpStatus = options.httpStatus; + this.body = options.body; + } +} + +export type WalletRegistrationServiceOptions = { + fetch: FetchLike; + baseUrl: string; + getAuthToken: () => Promise; +}; + +export type GetRegistrationStatusRequest = { + address: string; + blockchain: Blockchain; +}; + +export type RegisterSelfHostedWalletRequest = { + customerId: string; + address: string; + blockchain: Blockchain; + message: string; + signature: string; +}; + +/** Successful registration outcome. */ +export type RegistrationOutcome = { + type: 'registered'; + registration: SelfHostedRegistration; +}; + +const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; +const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; + +/** + * Normalizes a Monad EVM address for case-insensitive comparison. + * + * @param address - Raw address string. + * @returns The comparison key for the address. + */ +function normalizeAddress(address: string): string { + return address.toLowerCase(); +} + +/** + * Maps an HTTP status to the typed error kind the state machine reacts to. + * + * @param status - HTTP status code from the proxy/Iron response. + * @returns The corresponding error kind. + */ +function mapStatusToKind(status: number): WalletRegistrationErrorKind { + switch (status) { + case 400: + return 'validation'; + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'notFound'; + case 409: + return 'conflict'; + case 429: + return 'rateLimited'; + default: + return status >= 500 ? 'transient' : 'validation'; + } +} + +/** + * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * self-hosted wallet registration. It never calls Iron directly, so the Iron + * API key never ships in the client. + */ +export class WalletRegistrationService { + readonly #fetch: FetchLike; + + readonly #baseUrl: string; + + readonly #getAuthToken: () => Promise; + + constructor(options: WalletRegistrationServiceOptions) { + this.#fetch = options.fetch; + this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); + this.#getAuthToken = options.getAuthToken; + } + + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. Used when the current KYC flow has not already received + * `customer.id` from MoonPay's hosted frame. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + const token = await this.#getAuthToken(); + const response = await this.#fetch( + `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body was not valid JSON', + }); + } + + const { customerId } = payload as { customerId?: unknown }; + if (typeof customerId !== 'string' || customerId.length === 0) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body missing customerId', + }); + } + return customerId; + } + + /** + * Reconciles a wallet against the customer's registered self-hosted addresses. + * A failed or malformed lookup is reported as `lookupUnavailable` and never + * downgraded to `absent`. + * + * @param request - Monad address to reconcile. + * @returns The active / disabled / absent status for the address. + */ + async getRegistrationStatus( + request: GetRegistrationStatusRequest, + ): Promise { + const { address, blockchain } = request; + + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + } catch (error) { + throw new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + const body = await response.text(); + throw new WalletRegistrationError('lookupUnavailable', { + httpStatus: response.status, + body, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'self-hosted address list body was not valid JSON', + }); + } + if (!Array.isArray(payload)) { + throw new WalletRegistrationError('malformedResponse', { + message: 'expected an array of registered addresses', + }); + } + + const target = normalizeAddress(address); + const match = payload.find((entry) => { + const record = entry as Record; + const walletAddress = record.wallet_address; + if (typeof walletAddress !== 'string') { + return false; + } + return ( + normalizeAddress(walletAddress) === target && + record.blockchain === blockchain + ); + }) as Record | undefined; + + if (!match) { + return { type: 'absent' }; + } + + const registration = this.#toRegistration(match); + return registration.disabled + ? { type: 'disabled', registration } + : { type: 'active', registration }; + } + + /** + * Registers a self-hosted wallet through the MetaMask proxy. The proxy + * resolves the customer, derives the idempotency key, and attaches the API + * version, so the client never manages those. Every non-2xx response is + * mapped to a typed error; `409` is deliberately surfaced as an ambiguous + * `conflict` that the caller must reconcile with a follow-up status lookup. + * + * @param request - Customer id, address, blockchain, message, and signature. + * @returns The registered outcome on success. + */ + async registerSelfHostedWallet( + request: RegisterSelfHostedWalletRequest, + ): Promise { + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), + }); + } catch (error) { + throw new WalletRegistrationError('transient', { + message: 'self-hosted registration request failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body was not valid JSON', + }); + } + + const record = payload as Record; + if (typeof record.id !== 'string' || typeof record.address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body missing id/address', + }); + } + + return { + type: 'registered', + registration: { + id: record.id, + address: record.address, + blockchain: request.blockchain, + disabled: Boolean(record.disabled), + isSelf: true, + }, + }; + } + + async #toHttpError(response: HttpResponse): Promise { + let envelope: { message?: string }; + try { + envelope = (await response.json()) as { message?: string }; + } catch { + return new WalletRegistrationError('malformedResponse', { + httpStatus: response.status, + message: 'error body was not valid JSON', + }); + } + + const { status } = response; + const kind = mapStatusToKind(status); + return new WalletRegistrationError(kind, { + httpStatus: status, + body: envelope.message, + }); + } + + #toRegistration(record: Record): SelfHostedRegistration { + return { + id: String(record.id), + address: String(record.wallet_address), + blockchain: 'Monad', + disabled: Boolean(record.disabled), + isSelf: Boolean(record.is_self), + }; + } +} diff --git a/yarn.lock b/yarn.lock index 35c6bbed9c8..cc94a5d4955 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5740,7 +5740,7 @@ __metadata: resolution: "@metamask/account-tree-controller@workspace:packages/account-tree-controller" dependencies: "@metamask/account-api": "npm:^2.0.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^24.0.0" @@ -5772,7 +5772,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/accounts-controller@npm:^39.0.7, @metamask/accounts-controller@workspace:packages/accounts-controller": +"@metamask/accounts-controller@npm:^39.1.0, @metamask/accounts-controller@workspace:packages/accounts-controller": version: 0.0.0-use.local resolution: "@metamask/accounts-controller@workspace:packages/accounts-controller" dependencies: @@ -5978,7 +5978,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^13.1.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^13.1.2, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -5986,7 +5986,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.6.1" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6008,7 +6008,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6041,7 +6041,7 @@ __metadata: "@metamask/abi-utils": "npm:^2.0.3" "@metamask/account-api": "npm:^2.0.0" "@metamask/account-tree-controller": "npm:^7.6.1" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6071,7 +6071,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@tanstack/query-core": "npm:^5.62.16" "@ts-bridge/cli": "npm:^0.6.4" @@ -6251,7 +6251,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^79.0.1, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^79.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -6260,8 +6260,8 @@ __metadata: "@ethersproject/constants": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.7" - "@metamask/assets-controller": "npm:^13.1.1" + "@metamask/accounts-controller": "npm:^39.1.0" + "@metamask/assets-controller": "npm:^13.1.2" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -6278,7 +6278,7 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6302,10 +6302,10 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^79.0.1" + "@metamask/bridge-controller": "npm:^79.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" "@metamask/keyring-controller": "npm:^27.1.1" @@ -6315,7 +6315,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6410,17 +6410,20 @@ __metadata: languageName: unknown linkType: soft -"@metamask/claims-controller@workspace:packages/claims-controller": +"@metamask/claims-controller@npm:^0.6.0, @metamask/claims-controller@workspace:packages/claims-controller": version: 0.0.0-use.local resolution: "@metamask/claims-controller@workspace:packages/claims-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" + "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6464,7 +6467,7 @@ __metadata: "@metamask/core-backend": "npm:^8.1.1" "@metamask/keyring-api": "npm:^24.0.0" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -6811,7 +6814,7 @@ __metadata: "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/stake-sdk": "npm:^3.2.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -6835,7 +6838,7 @@ __metadata: "@metamask/preferences-controller": "npm:^23.1.0" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7420,7 +7423,7 @@ __metadata: "@metamask/snaps-controllers": "npm:^19.0.0" "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7685,6 +7688,7 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" + "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.4.1" @@ -7729,14 +7733,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/logging-controller@npm:^8.0.2, @metamask/logging-controller@workspace:packages/logging-controller": +"@metamask/logging-controller@npm:^9.0.0, @metamask/logging-controller@workspace:packages/logging-controller": version: 0.0.0-use.local resolution: "@metamask/logging-controller@workspace:packages/logging-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" deepmerge: "npm:^4.2.2" @@ -7907,7 +7911,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-controller@workspace:packages/money-account-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/eth-money-keyring": "npm:^4.0.0" @@ -7965,7 +7969,7 @@ __metadata: "@ethersproject/abstract-provider": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@metamask/auto-changelog": "npm:^6.1.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -7985,7 +7989,7 @@ __metadata: dependencies: "@ethereumjs/util": "npm:^9.1.0" "@metamask/account-api": "npm:^2.0.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8029,7 +8033,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-api-middleware@workspace:packages/multichain-api-middleware" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/api-specs": "npm:^0.15.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/chain-agnostic-permission": "npm:^1.7.0" @@ -8062,7 +8066,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-network-controller@workspace:packages/multichain-network-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8095,7 +8099,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/multichain-transactions-controller@workspace:packages/multichain-transactions-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/keyring-api": "npm:^24.0.0" @@ -8234,7 +8238,7 @@ __metadata: "@metamask/multichain-network-controller": "npm:^3.2.3" "@metamask/network-controller": "npm:^35.0.1" "@metamask/slip44": "npm:^4.3.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8436,7 +8440,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@myx-trade/sdk": "npm:^0.1.265" "@nktkas/hyperliquid": "npm:^0.33.1" @@ -8469,7 +8473,7 @@ __metadata: "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@noble/hashes": "npm:^1.8.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8583,7 +8587,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" @@ -8596,7 +8600,7 @@ __metadata: "@metamask/snaps-sdk": "npm:^11.0.0" "@metamask/snaps-utils": "npm:^12.1.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -8916,7 +8920,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/shield-controller@workspace:packages/shield-controller": +"@metamask/shield-controller@npm:^6.0.0, @metamask/shield-controller@workspace:packages/shield-controller": version: 0.0.0-use.local resolution: "@metamask/shield-controller@workspace:packages/shield-controller" dependencies: @@ -8925,11 +8929,14 @@ __metadata: "@lavamoat/preinstall-always-fail": "npm:^2.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/signature-controller": "npm:^39.2.9" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" cockatiel: "npm:^3.1.2" @@ -8949,7 +8956,7 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/signature-controller@workspace:packages/signature-controller" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -8957,7 +8964,7 @@ __metadata: "@metamask/eth-sig-util": "npm:^8.2.0" "@metamask/gator-permissions-controller": "npm:^5.0.1" "@metamask/keyring-controller": "npm:^27.1.1" - "@metamask/logging-controller": "npm:^8.0.2" + "@metamask/logging-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/network-controller": "npm:^35.0.1" "@metamask/utils": "npm:^11.11.0" @@ -9012,7 +9019,7 @@ __metadata: "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" bignumber.js: "npm:^9.1.2" @@ -9261,18 +9268,21 @@ __metadata: languageName: unknown linkType: soft -"@metamask/subscription-controller@workspace:packages/subscription-controller": +"@metamask/subscription-controller@npm:^7.0.0, @metamask/subscription-controller@workspace:packages/subscription-controller": version: 0.0.0-use.local resolution: "@metamask/subscription-controller@workspace:packages/subscription-controller" dependencies: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" + "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/polling-controller": "npm:^16.0.9" "@metamask/profile-sync-controller": "npm:^29.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" + "@tanstack/query-core": "npm:^4.43.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" bignumber.js: "npm:^9.1.2" @@ -9318,7 +9328,7 @@ __metadata: languageName: node linkType: hard -"@metamask/transaction-controller@npm:^69.5.0, @metamask/transaction-controller@workspace:packages/transaction-controller": +"@metamask/transaction-controller@npm:^69.5.2, @metamask/transaction-controller@workspace:packages/transaction-controller": version: 0.0.0-use.local resolution: "@metamask/transaction-controller@workspace:packages/transaction-controller" dependencies: @@ -9330,7 +9340,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/wallet": "npm:^5.7.0" - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -9383,7 +9393,7 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^13.1.1" + "@metamask/assets-controller": "npm:^13.1.2" "@metamask/assets-controllers": "npm:^111.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" @@ -9396,7 +9406,7 @@ __metadata: "@metamask/ramps-controller": "npm:^20.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/sentinel-api-service": "npm:^1.0.0" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -9431,7 +9441,7 @@ __metadata: "@metamask/polling-controller": "npm:^16.0.9" "@metamask/rpc-errors": "npm:^7.0.2" "@metamask/superstruct": "npm:^3.4.1" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" @@ -9504,7 +9514,7 @@ __metadata: "@metamask/storage-service": "npm:^1.0.2" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^11.11.0" - "@metamask/wallet": "npm:^9.0.0" + "@metamask/wallet": "npm:^10.0.0" "@oclif/core": "npm:^4.10.5" "@ts-bridge/cli": "npm:^0.6.4" "@types/better-sqlite3": "npm:^7.6.13" @@ -9556,16 +9566,17 @@ __metadata: languageName: unknown linkType: soft -"@metamask/wallet@npm:^9.0.0, @metamask/wallet@workspace:packages/wallet": +"@metamask/wallet@npm:^10.0.0, @metamask/wallet@workspace:packages/wallet": version: 0.0.0-use.local resolution: "@metamask/wallet@workspace:packages/wallet" dependencies: - "@metamask/accounts-controller": "npm:^39.0.7" + "@metamask/accounts-controller": "npm:^39.1.0" "@metamask/address-book-controller": "npm:^7.1.2" "@metamask/approval-controller": "npm:^9.0.2" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/browser-passworder": "npm:^6.0.0" + "@metamask/claims-controller": "npm:^0.6.0" "@metamask/connectivity-controller": "npm:^0.3.0" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/gas-fee-controller": "npm:^26.3.1" @@ -9576,8 +9587,10 @@ __metadata: "@metamask/remote-feature-flag-controller": "npm:^5.0.0" "@metamask/scure-bip39": "npm:^2.1.1" "@metamask/seedless-onboarding-controller": "npm:^10.1.1" + "@metamask/shield-controller": "npm:^6.0.0" "@metamask/storage-service": "npm:^1.0.2" - "@metamask/transaction-controller": "npm:^69.5.0" + "@metamask/subscription-controller": "npm:^7.0.0" + "@metamask/transaction-controller": "npm:^69.5.2" "@metamask/utils": "npm:^11.11.0" "@ts-bridge/cli": "npm:^0.6.4" "@types/jest": "npm:^30.0.0" From e3c13f924f5936735cde1432081702fe0dfbbc67 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:50:52 -0600 Subject: [PATCH 53/67] feat(ramps): add NeoBankService Pix and quote client methods Expose messenger-backed neo-bank proxy helpers for Pix send and autoramp quotes under the live `/neobank` prefix, and align getAutoramp with that path. --- packages/ramps-controller/CHANGELOG.md | 5 + .../src/NeoBankService-method-action-types.ts | 90 +++- .../src/NeoBankService.test.ts | 449 +++++++++++++++++- .../ramps-controller/src/NeoBankService.ts | 229 ++++++++- packages/ramps-controller/src/index.ts | 13 +- 5 files changed, 732 insertions(+), 54 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index c3573e3e5c8..557119c6903 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,8 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) +### Changed + +- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. + ## [20.0.0] ### Changed diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 48dcc1839b7..3343ed3ba87 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -6,8 +6,9 @@ import type { NeoBankService } from './NeoBankService.js'; /** - * Fetches an autoramp account via the Ramp API proxy of - * MoonPay `GET /api/autoramps/{autoramp_id}`. + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). * * @param autorampId - MoonPay / Ramp API autoramp id. * @returns Remote snapshot for controller apply/refresh. @@ -17,7 +18,90 @@ export type NeoBankServiceGetAutorampAction = { handler: NeoBankService['getAutoramp']; }; +/** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceRegisterPixAddressAction = { + type: `NeoBankService:registerPixAddress`; + handler: NeoBankService['registerPixAddress']; +}; + +/** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteAction = { + type: `NeoBankService:getAutorampQuote`; + handler: NeoBankService['getAutorampQuote']; +}; + +/** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ +export type NeoBankServiceCreateAutorampAction = { + type: `NeoBankService:createAutoramp`; + handler: NeoBankService['createAutoramp']; +}; + +/** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetAutorampQuoteForAutorampAction = { + type: `NeoBankService:getAutorampQuoteForAutoramp`; + handler: NeoBankService['getAutorampQuoteForAutoramp']; +}; + +/** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceAttachAutorampQuoteAction = { + type: `NeoBankService:attachAutorampQuote`; + handler: NeoBankService['attachAutorampQuote']; +}; + +/** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ +export type NeoBankServiceGetCustomerByExternalIdAction = { + type: `NeoBankService:getCustomerByExternalId`; + handler: NeoBankService['getCustomerByExternalId']; +}; + /** * Union of all NeoBankService action types. */ -export type NeoBankServiceMethodActions = NeoBankServiceGetAutorampAction; +export type NeoBankServiceMethodActions = + | NeoBankServiceGetAutorampAction + | NeoBankServiceRegisterPixAddressAction + | NeoBankServiceGetAutorampQuoteAction + | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetAutorampQuoteForAutorampAction + | NeoBankServiceAttachAutorampQuoteAction + | NeoBankServiceGetCustomerByExternalIdAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index a0765b00024..c2f40d582ac 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -9,7 +9,61 @@ import { RampsEnvironment } from './RampsService.js'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; +const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; + +/** + * Builds a NeoBankService with AuthenticationController bearer auth stubbed. + * + * @param options - Optional constructor overrides. Pass `omitDefaults: true` to + * exercise constructor defaulted parameters (`environment`, `policyOptions`). + * @returns Service instance for the test. + */ +function createService(options?: { + environment?: RampsEnvironment; + baseUrlOverride?: string; + omitDefaults?: boolean; +}): NeoBankService { + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => 'test-token', + ); + + const messenger = new Messenger({ + namespace: 'NeoBankService', + parent: rootMessenger, + }) as unknown as NeoBankServiceMessenger; + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + }); + + if (options?.omitDefaults) { + return new NeoBankService({ + messenger, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + baseUrlOverride: options.baseUrlOverride, + }); + } + + return new NeoBankService({ + messenger, + environment: options?.environment ?? RampsEnvironment.Staging, + context: 'test', + fetch: globalThis.fetch.bind(globalThis), + policyOptions: { maxRetries: 0 }, + baseUrlOverride: options?.baseUrlOverride, + }); +} + describe('NeoBankService', () => { + afterEach(() => { + nock.cleanAll(); + }); + describe('mapNeoBankAutorampToRemoteSnapshot', () => { it('maps MoonPay-shaped fields into a remote snapshot', () => { expect( @@ -28,29 +82,38 @@ describe('NeoBankService', () => { depositRailsSummary: { ready: true }, }); }); - }); - describe('getAutoramp', () => { - it('GETs the proxied autoramp endpoint with bearer auth', async () => { - const rootMessenger = new Messenger({ - namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, + it('falls back to recipient_account.address when wallet_address is absent', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Pending', + recipient_account: { address: '0xfrom-recipient' }, + }), + ).toMatchObject({ + walletAddress: '0xfrom-recipient', + depositRailsSummary: undefined, }); - rootMessenger.registerActionHandler( - 'AuthenticationController:getBearerToken', - async () => 'test-token', - ); + }); - const messenger = new Messenger({ - namespace: 'NeoBankService', - parent: rootMessenger, - }) as unknown as NeoBankServiceMessenger; - rootMessenger.delegate({ - messenger, - actions: ['AuthenticationController:getBearerToken'], + it('marks deposit rails not ready when Approved without rails', () => { + expect( + mapNeoBankAutorampToRemoteSnapshot({ + id: 'ar-1', + customer_id: 'cust-1', + status: 'Approved', + }), + ).toMatchObject({ + depositRailsSummary: { ready: false }, }); + }); + }); - const scope = nock('https://on-ramp.uat-api.cx.metamask.io') - .get(/\/api\/v2\/autoramps\/ar-1/u) + describe('getAutoramp', () => { + it('GETs /neobank/autoramps/{id} with bearer auth', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) .matchHeader('Authorization', 'Bearer test-token') .reply(200, { id: 'ar-1', @@ -59,13 +122,7 @@ describe('NeoBankService', () => { wallet_address: '0xabc', }); - const service = new NeoBankService({ - messenger, - environment: RampsEnvironment.Staging, - context: 'test', - fetch: globalThis.fetch.bind(globalThis), - }); - + const service = createService(); const snapshot = await service.getAutoramp('ar-1'); expect(scope.isDone()).toBe(true); @@ -76,5 +133,347 @@ describe('NeoBankService', () => { walletAddress: '0xabc', }); }); + + it('throws HttpError when the proxy returns a non-2xx status', async () => { + nock(STAGING_BASE).get(/\/neobank\/autoramps\/missing/u).reply(404); + + const service = createService(); + await expect(service.getAutoramp('missing')).rejects.toThrow( + /failed with status '404'/u, + ); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { status: 'Authorized' }); + + const service = createService(); + await expect(service.getAutoramp('ar-1')).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('registerPixAddress', () => { + it('POSTs /neobank/addresses/pix with JSON body and bearer auth', async () => { + const body = { + type: 'Pix', + pix_key: 'user@example.com', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { id: 'addr-1', ...body }); + + const service = createService(); + const result = await service.registerPixAddress(body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'addr-1' }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/addresses/pix', { pix_key: 'k' }) + .query(true) + .matchHeader('Idempotency-Key', 'idem-1') + .reply(200, { id: 'addr-1' }); + + const service = createService(); + await service.registerPixAddress( + { pix_key: 'k' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(scope.isDone()).toBe(true); + }); + }); + + describe('getAutorampQuote', () => { + it('GETs /neobank/autoramps/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '100' && + query.currency === 'BRL' && + typeof query.sdk === 'string' && + typeof query.controller === 'string' && + query.context === 'test' + ); + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-1', amount: '100' }); + + const service = createService(); + const result = await service.getAutorampQuote({ + amount: '100', + currency: 'BRL', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-1' }); + }); + }); + + describe('createAutoramp', () => { + it('POSTs /neobank/autoramps and maps the Autoramp response', async () => { + const body = { + signed_quote: 'sig', + customer_id: 'cust-1', + }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(201, { + id: 'ar-new', + customer_id: 'cust-1', + status: 'Pending', + wallet_address: '0xdef', + }); + + const service = createService(); + const snapshot = await service.createAutoramp(body); + + expect(scope.isDone()).toBe(true); + expect(snapshot).toMatchObject({ + id: 'ar-new', + customerId: 'cust-1', + status: 'Pending', + walletAddress: '0xdef', + }); + }); + + it('forwards Idempotency-Key when provided', async () => { + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps', { signed_quote: 'sig' }) + .query(true) + .matchHeader('Idempotency-Key', 'create-idem') + .reply(201, { + id: 'ar-2', + customer_id: 'cust-1', + status: 'Pending', + }); + + const service = createService(); + await service.createAutoramp( + { signed_quote: 'sig' }, + { idempotencyKey: 'create-idem' }, + ); + + expect(scope.isDone()).toBe(true); + }); + + it('throws when the response body is malformed', async () => { + nock(STAGING_BASE) + .post('/neobank/autoramps') + .query(true) + .reply(201, { status: 'Pending' }); + + const service = createService(); + await expect( + service.createAutoramp({ signed_quote: 'sig' }), + ).rejects.toThrow( + 'Malformed response received from neo-bank autoramp API', + ); + }); + }); + + describe('getAutorampQuoteForAutoramp', () => { + it('GETs /neobank/autoramps/{id}/quote with query params', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query((query) => { + return query.amount === '50' && query.context === 'test'; + }) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { quote_id: 'q-2' }); + + const service = createService(); + const result = await service.getAutorampQuoteForAutoramp('ar-1', { + amount: '50', + }); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-2' }); + }); + }); + + describe('attachAutorampQuote', () => { + it('POSTs /neobank/autoramps/{id}/quotes with JSON body', async () => { + const body = { signed_quote: 'attach-sig' }; + + const scope = nock(STAGING_BASE) + .post('/neobank/autoramps/ar-1/quotes', body) + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .matchHeader('Content-Type', 'application/json') + .reply(200, { quote_id: 'q-attached' }); + + const service = createService(); + const result = await service.attachAutorampQuote('ar-1', body); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ quote_id: 'q-attached' }); + }); + }); + + describe('getCustomerByExternalId', () => { + it('GETs /neobank/customers/{external_id}/external', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/customers/ext-1/external') + .query(true) + .matchHeader('Authorization', 'Bearer test-token') + .reply(200, { id: 'cust-1', external_id: 'ext-1' }); + + const service = createService(); + const result = await service.getCustomerByExternalId('ext-1'); + + expect(scope.isDone()).toBe(true); + expect(result).toMatchObject({ id: 'cust-1', external_id: 'ext-1' }); + }); + }); + + describe('environments and policy hooks', () => { + it.each([ + [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'], + [RampsEnvironment.Development, 'https://on-ramp.dev-api.cx.metamask.io'], + [RampsEnvironment.Local, 'http://localhost:3000'], + ] as const)( + 'uses the %s host for getAutoramp', + async (environment, host) => { + const scope = nock(host) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ environment }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }, + ); + + it('uses constructor defaults for environment and policyOptions', async () => { + const scope = nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ omitDefaults: true }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('calls getAutorampQuote and getAutorampQuoteForAutoramp without query', async () => { + const quoteScope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query(true) + .reply(200, { quote_id: 'q-default' }); + const forAutorampScope = nock(STAGING_BASE) + .get('/neobank/autoramps/ar-1/quote') + .query(true) + .reply(200, { quote_id: 'q-for-ar' }); + + const service = createService(); + await service.getAutorampQuote(); + await service.getAutorampQuoteForAutoramp('ar-1'); + + expect(quoteScope.isDone()).toBe(true); + expect(forAutorampScope.isDone()).toBe(true); + }); + + it('uses baseUrlOverride when provided', async () => { + const scope = nock('http://custom-neobank.test') + .get(/\/neobank\/autoramps\/ar-1/u) + .reply(200, { + id: 'ar-1', + customer_id: 'cust-1', + status: 'Authorized', + }); + + const service = createService({ + baseUrlOverride: 'http://custom-neobank.test', + }); + await service.getAutoramp('ar-1'); + + expect(scope.isDone()).toBe(true); + }); + + it('throws for an invalid environment', async () => { + await expect( + createService({ + environment: 'bogus' as RampsEnvironment, + }).getAutoramp('ar-1'), + ).rejects.toThrow(/Invalid environment/u); + }); + + it('throws HttpError on non-2xx POST responses', async () => { + nock(STAGING_BASE) + .post('/neobank/addresses/pix') + .query(true) + .reply(422, { error: 'bad' }); + + const service = createService(); + await expect( + service.registerPixAddress({ pix_key: 'k' }), + ).rejects.toThrow(/failed with status '422'/u); + }); + + it('omits nullish query values when building quote URLs', async () => { + const scope = nock(STAGING_BASE) + .get('/neobank/autoramps/quote') + .query((query) => { + return ( + query.amount === '10' && + query.currency === undefined && + query.optional === undefined + ); + }) + .reply(200, { quote_id: 'q-nullish' }); + + const service = createService(); + await service.getAutorampQuote({ + amount: '10', + currency: undefined, + optional: null, + }); + + expect(scope.isDone()).toBe(true); + }); + + it('registers onRetry, onBreak, and onDegraded listeners', () => { + const service = createService(); + const onRetry = jest.fn(); + const onBreak = jest.fn(); + const onDegraded = jest.fn(); + + const retrySub = service.onRetry(onRetry); + const breakSub = service.onBreak(onBreak); + const degradedSub = service.onDegraded(onDegraded); + + expect(typeof retrySub.dispose).toBe('function'); + expect(typeof breakSub.dispose).toBe('function'); + expect(typeof degradedSub.dispose).toBe('function'); + + retrySub.dispose(); + breakSub.dispose(); + degradedSub.dispose(); + }); }); }); diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index ca8f5dd7b0a..e13d8b1ef1d 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -40,7 +40,34 @@ export type NeoBankAutorampResponse = { deposit_rails?: unknown[]; }; -const MESSENGER_EXPOSED_METHODS = ['getAutoramp'] as const; +/** + * Optional headers for neo-bank mutating requests. + */ +export type NeoBankRequestOptions = { + /** + * Forwarded as `Idempotency-Key` when set (MoonPay requires it on some POSTs; + * neobank-proxy generates one when omitted). + */ + idempotencyKey?: string; +}; + +/** + * Query string values accepted by neo-bank GET helpers. + */ +export type NeoBankQueryParams = Record< + string, + string | number | boolean | undefined | null +>; + +const MESSENGER_EXPOSED_METHODS = [ + 'getAutoramp', + 'registerPixAddress', + 'getAutorampQuote', + 'createAutoramp', + 'getAutorampQuoteForAutoramp', + 'attachAutorampQuote', + 'getCustomerByExternalId', +] as const; /** * Actions that {@link NeoBankService} exposes to other consumers. @@ -65,14 +92,17 @@ export type NeoBankServiceMessenger = Messenger< >; /** - * Builds an `/api/v2/...` path for the Ramp API neo-bank proxy. + * Builds a path under the neobank-proxy global prefix. * - * @param path - Path under the versioned API root (no leading slash). - * @param version - API version segment. - * @returns Versioned API path. + * Live neobank-proxy (#1124) mounts routes at `/neobank` on the on-ramp.api + * host (ALB path routing, no rewrite). Prefer this over `/api/v2/...` so Core + * matches the proxy that ships. + * + * @param path - Path under `/neobank` (no leading slash). + * @returns Absolute path segment for URL join against the Ramp API host. */ -function getApiPath(path: string, version: string = 'v2'): string { - return `api/${version}/${path.replace(/^\//u, '')}`; +function getNeoBankPath(path: string): string { + return `neobank/${path.replace(/^\//u, '')}`; } /** @@ -128,8 +158,10 @@ export function mapNeoBankAutorampToRemoteSnapshot( * Client for MetaMask Ramp API neo-bank endpoints (MoonPay Enterprise proxy). * * Lives alongside {@link RampsService} and {@link TransakService}. Authentication - * and MoonPay partner headers are handled by the Ramp API — this service only + * and MoonPay partner headers are handled by the Ramp API; this service only * attaches the MetaMask user bearer token. + * + * Paths use the neobank-proxy `/neobank` prefix on the on-ramp.api host. */ export class NeoBankService { readonly name: typeof serviceName; @@ -182,32 +214,42 @@ export class NeoBankService { return getBaseUrl(this.#environment); } - async #getRequestHeaders(): Promise> { + async #getRequestHeaders( + options: NeoBankRequestOptions = {}, + ): Promise> { const bearerToken = await this.#messenger.call( 'AuthenticationController:getBearerToken', ); - return { + const headers: Record = { Authorization: `Bearer ${bearerToken}`, }; + if (options.idempotencyKey) { + headers['Idempotency-Key'] = options.idempotencyKey; + } + return headers; } - /** - * Fetches an autoramp account via the Ramp API proxy of - * MoonPay `GET /api/autoramps/{autoramp_id}`. - * - * @param autorampId - MoonPay / Ramp API autoramp id. - * @returns Remote snapshot for controller apply/refresh. - */ - async getAutoramp(autorampId: string): Promise { - const url = new URL( - getApiPath(`autoramps/${encodeURIComponent(autorampId)}`), - this.#getBaseUrl(), - ); + #buildUrl(path: string, query?: NeoBankQueryParams): URL { + const url = new URL(getNeoBankPath(path), this.#getBaseUrl()); url.searchParams.set('sdk', RAMPS_SDK_VERSION); url.searchParams.set('controller', packageJson.version); url.searchParams.set('context', this.#context); + if (query) { + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, String(value)); + } + } + } + return url; + } - const response = await this.#policy.execute(async () => { + async #getJson( + path: string, + query?: NeoBankQueryParams, + ): Promise { + const url = this.#buildUrl(path, query); + return this.#policy.execute(async () => { const headers = await this.#getRequestHeaders(); const fetchResponse = await this.#fetch(url, { headers }); if (!fetchResponse.ok) { @@ -216,16 +258,153 @@ export class NeoBankService { `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, ); } - return fetchResponse.json() as Promise; + return fetchResponse.json() as Promise; + }); + } + + async #postJson( + path: string, + body: Record, + options: NeoBankRequestOptions, + ): Promise { + const url = this.#buildUrl(path); + return this.#policy.execute(async () => { + const headers = await this.#getRequestHeaders(options); + headers['Content-Type'] = 'application/json'; + const fetchResponse = await this.#fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(body), + }); + if (!fetchResponse.ok) { + throw new HttpError( + fetchResponse.status, + `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, + ); + } + return fetchResponse.json() as Promise; }); + } + #mapAutorampResponse(response: NeoBankAutorampResponse): AutorampRemoteSnapshot { if (!response || typeof response !== 'object' || !response.id) { throw new Error('Malformed response received from neo-bank autoramp API'); } - return mapNeoBankAutorampToRemoteSnapshot(response); } + /** + * Fetches an autoramp account via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}` (MoonPay + * `GET /api/autoramps/{autoramp_id}`). + * + * @param autorampId - MoonPay / Ramp API autoramp id. + * @returns Remote snapshot for controller apply/refresh. + */ + async getAutoramp(autorampId: string): Promise { + const response = await this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}`, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Registers a Pix address via neobank-proxy `POST /neobank/addresses/pix`. + * Body is forwarded as opaque JSON (MoonPay address schema). + * + * @param body - Pix address registration payload. + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async registerPixAddress( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson('addresses/pix', body, options); + } + + /** + * Fetches an autoramp quote via neobank-proxy `GET /neobank/autoramps/quote`. + * + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuote(query: NeoBankQueryParams = {}): Promise { + return this.#getJson('autoramps/quote', query); + } + + /** + * Creates an autoramp from a signed quote via neobank-proxy + * `POST /neobank/autoramps` (MoonPay `POST /api/autoramps`). + * + * @param body - CreateAutoramp / signed-quote payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Remote snapshot for controller apply/refresh. + */ + async createAutoramp( + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + const response = await this.#postJson( + 'autoramps', + body, + options, + ); + return this.#mapAutorampResponse(response); + } + + /** + * Fetches a quote for an existing autoramp via neobank-proxy + * `GET /neobank/autoramps/{autoramp_id}/quote`. + * + * @param autorampId - Autoramp id. + * @param query - Quote query params (forwarded as-is). + * @returns Parsed proxy JSON response. + */ + async getAutorampQuoteForAutoramp( + autorampId: string, + query: NeoBankQueryParams = {}, + ): Promise { + return this.#getJson( + `autoramps/${encodeURIComponent(autorampId)}/quote`, + query, + ); + } + + /** + * Attaches a signed quote to an autoramp via neobank-proxy + * `POST /neobank/autoramps/{autoramp_id}/quotes`. + * + * @param autorampId - Autoramp id. + * @param body - Quote attachment payload (forwarded as-is). + * @param options - Optional idempotency key. + * @returns Parsed proxy JSON response. + */ + async attachAutorampQuote( + autorampId: string, + body: Record, + options: NeoBankRequestOptions = {}, + ): Promise { + return this.#postJson( + `autoramps/${encodeURIComponent(autorampId)}/quotes`, + body, + options, + ); + } + + /** + * Fetches a customer by partner external id via neobank-proxy + * `GET /neobank/customers/{external_id}/external`. + * + * @param externalId - Partner-assigned external customer id. + * @returns Parsed proxy JSON response. + */ + async getCustomerByExternalId(externalId: string): Promise { + return this.#getJson( + `customers/${encodeURIComponent(externalId)}/external`, + ); + } + onRetry( listener: Parameters[0], ): ReturnType { diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index eb84a47d863..5dbe85e45ed 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -211,8 +211,19 @@ export type { NeoBankServiceEvents, NeoBankServiceMessenger, NeoBankAutorampResponse, + NeoBankRequestOptions, + NeoBankQueryParams, } from './NeoBankService.js'; -export type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +export type { + NeoBankServiceGetAutorampAction, + NeoBankServiceRegisterPixAddressAction, + NeoBankServiceGetAutorampQuoteAction, + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampQuoteForAutorampAction, + NeoBankServiceAttachAutorampQuoteAction, + NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceMethodActions, +} from './NeoBankService-method-action-types.js'; export { NeoBankService, serviceName as neoBankServiceName, From ecb94e8faaa1a6632c875ffe36158b11e41b2ded Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 14:02:34 -0600 Subject: [PATCH 54/67] feat(kyc-controller): sync wallet registration with neobank-proxy Retarget Money Account self-hosted wallet registration from kyc-api /vendors/moonpay/* to onramp-api neobank-proxy /neobank/... so Core matches Money Movement ownership and transparent proxy semantics. --- packages/kyc-controller/CHANGELOG.md | 6 +- .../src/KycController-method-action-types.ts | 6 +- .../kyc-controller/src/KycController.test.ts | 17 +- packages/kyc-controller/src/KycController.ts | 32 ++- .../src/KycService-method-action-types.ts | 10 +- .../kyc-controller/src/KycService.test.ts | 77 +++++- packages/kyc-controller/src/KycService.ts | 69 ++++- packages/kyc-controller/src/index.ts | 1 + .../src/wallet-registration-service.test.ts | 260 ++++++++++++++---- .../src/wallet-registration-service.ts | 171 +++++++++--- 10 files changed, 514 insertions(+), 135 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index a6c6828a594..5e8d9608321 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,8 +10,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) -- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847)) -- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847)) +- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask neobank-proxy ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) +- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) + - Targets transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`), client-side Monad filtering, `Idempotency-Key`, and upstream error bodies mirrored 1:1. + - Optional `neobankBaseUrl` on `KycService` so KYC and wallet registration can use different hosts. - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 7f27b8c202e..0c6b05f21c4 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -168,11 +168,13 @@ export type KycControllerGetSessionStatusAction = { }; /** - * Registers a Money Account wallet with MoonPay Iron. + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller reuses the Iron * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id from the authenticated MetaMask profile via KycService. + * it resolves the id via `GET /neobank/customers/{external_id}/external` + * (MetaMask canonical profile id). Customer resolution happens before the + * first list/lookup because list requires `customer_id` in the path. * Message construction, signing, submission, and ambiguous-write * reconciliation stay internal to KYC. * diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 2f2fbaf2458..8cfecaace3c 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1763,6 +1763,11 @@ describe('KycController', () => { type: 'alreadyRegistered', registration, }); + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); }); }); @@ -1790,6 +1795,10 @@ describe('KycController', () => { ).toMatchObject({ type: 'registered' }); expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'frame-customer', + address: '0xabc', + }); expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ data: expect.stringContaining('as customer frame-customer.'), from: '0xabc', @@ -1799,20 +1808,26 @@ describe('KycController', () => { address: '0xabc', customerId: 'frame-customer', signature: '0xsig', + idempotencyKey: expect.any(String), }), ); }, ); }); - it('falls back to resolving the customer id from the proxy', async () => { + it('falls back to resolving the customer id from the proxy before list', async () => { await withController(async ({ controller, handlers }) => { await controller.registerMoneyAccountWallet({ address: '0xabc' }); expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( expect.objectContaining({ customerId: 'iron-customer-fallback', + idempotencyKey: expect.any(String), }), ); }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 39a9e9e7acf..3b3e7b7d888 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -37,11 +37,14 @@ import { createInitialState, transition as transitionWalletRegistration, } from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; import type { RegistrationStatus, SelfHostedRegistration, } from './wallet-registration-service.js'; -import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -1412,11 +1415,13 @@ export class KycController extends BaseController< } /** - * Registers a Money Account wallet with MoonPay Iron. + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller reuses the Iron * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id from the authenticated MetaMask profile via KycService. + * it resolves the id via `GET /neobank/customers/{external_id}/external` + * (MetaMask canonical profile id). Customer resolution happens before the + * first list/lookup because list requires `customer_id` in the path. * Message construction, signing, submission, and ambiguous-write * reconciliation stay internal to KYC. * @@ -1448,11 +1453,17 @@ export class KycController extends BaseController< return undefined; }; + // List requires customer_id in the neobank path, so resolve Iron's id + // before the first lookup. Prefer the ephemeral frame-captured value. + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + const lookup = async (): Promise => { try { return await this.messenger.call( 'KycService:getWalletRegistrationStatus', - { address }, + { customerId, address }, ); } catch (error) { machine = transitionWalletRegistration(machine, { @@ -1484,9 +1495,10 @@ export class KycController extends BaseController< return existingResult; } - const customerId = - this.state.moonpayCustomerId ?? - (await this.messenger.call('KycService:getMoonpayCustomerId')); + // Stable across transient retries of the same ownership proof; refreshed + // when the UTC-dated message must be rebuilt and re-signed. + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; while (true) { const message = buildOwnershipMessage({ @@ -1494,6 +1506,11 @@ export class KycController extends BaseController< customerId, now: new Date(), }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + let signature: string; try { signature = await this.messenger.call( @@ -1517,6 +1534,7 @@ export class KycController extends BaseController< customerId, message, signature, + idempotencyKey, }, ); machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 344ae092e5c..b926752b57c 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -18,8 +18,8 @@ export type KycServiceGetGeoCountryAction = { }; /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. * * @returns Iron's internal customer id. */ @@ -29,9 +29,11 @@ export type KycServiceGetMoonpayCustomerIdAction = { }; /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. * @param params.address - Money Account address. * @returns Active, disabled, or absent registration status. */ diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index b537d96fa0a..84512afa87d 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -60,38 +60,49 @@ describe('KycService', () => { }); describe('Money Account wallet registration', () => { - it('resolves the Iron customer id', async () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/customer') + .get('/neobank/customers/canonical-profile-1/external') .matchHeader('authorization', 'Bearer test-bearer') - .reply(200, { customerId: 'iron-customer-1' }); + .reply(200, { + id: 'iron-customer-1', + external_id: 'canonical-profile-1', + }); const { service } = getService(); expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); }); - it('checks Monad wallet registration status', async () => { + it('checks Monad wallet registration status for a customer', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/self-hosted-wallets') + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) .reply(200, []); const { service } = getService(); expect( - await service.getWalletRegistrationStatus({ address: '0xabc' }), + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), ).toStrictEqual({ type: 'absent' }); }); - it('submits a signed Monad wallet ownership proof', async () => { + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { nock(MOCK_API_URL) - .post('/vendors/moonpay/self-hosted-wallets', { - customer_id: 'iron-customer-1', - address: '0xabc', - blockchain: 'Monad', - message: 'ownership message', - signature: '0xsig', - }) + .post( + '/neobank/addresses/crypto/selfhosted', + { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }, + { reqheaders: { 'idempotency-key': 'idem-1' } }, + ) .reply(200, { id: 'wallet-1', address: '0xabc', @@ -106,12 +117,32 @@ describe('KycService', () => { address: '0xabc', message: 'ownership message', signature: '0xsig', + idempotencyKey: 'idem-1', }), ).toMatchObject({ type: 'registered', registration: { id: 'wallet-1', blockchain: 'Monad' }, }); }); + + it('uses neobankBaseUrl when provided for wallet routes', async () => { + const neobankUrl = 'https://on-ramp.dev-api.cx.metamask.io'; + nock(neobankUrl) + .get('/neobank/customers/canonical-profile-1/external') + .reply(200, { id: 'iron-customer-1' }); + + const { service } = getService({ neobankBaseUrl: neobankUrl }); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('throws when the session profile has no usable external id', async () => { + const { service } = getService({ canonicalProfileId: '' }); + + await expect(service.getMoonpayCustomerId()).rejects.toThrow( + /Unable to resolve MetaMask canonical profile id/u, + ); + }); }); describe('fetchDisclaimers', () => { @@ -536,8 +567,11 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - Base URL of the KYC API. + * @param args.neobankBaseUrl - Optional on-ramp / neobank-proxy base URL. * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the * option so the service falls back to an empty string. + * @param args.canonicalProfileId - Canonical profile id returned by + * `AuthenticationController:getSessionProfile`. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -545,15 +579,19 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl = MOCK_API_URL, + neobankBaseUrl, // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, + canonicalProfileId = 'canonical-profile-1', }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; + neobankBaseUrl?: string; fractalEncryptionBaseUrl?: string | null; + canonicalProfileId?: string; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -569,6 +607,7 @@ function getService({ rootMessenger.delegate({ actions: [ 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', 'GeolocationController:getGeolocation', ], events: [], @@ -578,6 +617,15 @@ function getService({ 'AuthenticationController:getBearerToken', async () => bearerToken, ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => ({ + identifierId: 'id-1', + profileId: canonicalProfileId, + canonicalProfileId, + metaMetricsId: 'mm-1', + }), + ); rootMessenger.registerActionHandler( 'GeolocationController:getGeolocation', async () => geolocation as string, @@ -587,6 +635,7 @@ function getService({ fetch, messenger, baseUrl, + ...(neobankBaseUrl === undefined ? {} : { neobankBaseUrl }), ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 1674a0d3cbf..cf18213cd64 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -8,7 +8,10 @@ import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; import { HttpError } from '@metamask/controller-utils'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { Messenger } from '@metamask/messenger'; -import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { + AuthenticationControllerGetBearerTokenAction, + AuthenticationControllerGetSessionProfileAction, +} from '@metamask/profile-sync-controller/auth'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -77,6 +80,7 @@ export type KycServiceActions = */ type AllowedActions = | AuthenticationControllerGetBearerTokenAction + | AuthenticationControllerGetSessionProfileAction | GeolocationControllerGetGeolocationAction; /** @@ -124,6 +128,13 @@ export type KycServiceOptions = { * Mandatory value that sets the base url to KYC api */ baseUrl: string; + /** + * Base URL of the on-ramp / neobank-proxy host used for Money Account wallet + * registration (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are + * under `/neobank`. When omitted, falls back to {@link baseUrl} so local + * tests can target a single mock host. + */ + neobankBaseUrl?: string; /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is @@ -256,11 +267,21 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type GetWalletRegistrationStatusParams = { + customerId: string; + address: string; +}; + export type RegisterSelfHostedWalletParams = { customerId: string; address: string; message: string; signature: string; + /** + * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable + * key across retries of the same ownership body. + */ + idempotencyKey?: string; }; // === SERVICE DEFINITION === @@ -297,6 +318,8 @@ export class KycService extends BaseDataService< * @param options.messenger - The messenger suited for this service. * @param options.fetch - A function used to make HTTP requests. * @param options.baseUrl - Base URL of the KYC API + * @param options.neobankBaseUrl - Base URL of the neobank-proxy host for + * wallet registration. Defaults to `baseUrl` when omitted. * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. @@ -308,6 +331,7 @@ export class KycService extends BaseDataService< messenger, fetch: fetchFunction, baseUrl, + neobankBaseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, policyOptions = {}, @@ -326,8 +350,10 @@ export class KycService extends BaseDataService< this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.#walletRegistrationService = new WalletRegistrationService({ fetch: fetchFunction, - baseUrl, + baseUrl: neobankBaseUrl ?? baseUrl, getAuthToken: async (): Promise => this.#getBearerToken(), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), }); this.messenger.registerMethodActionHandlers( this, @@ -369,8 +395,8 @@ export class KycService extends BaseDataService< } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. * * @returns Iron's internal customer id. */ @@ -379,18 +405,20 @@ export class KycService extends BaseDataService< } /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. * @param params.address - Money Account address. * @returns Active, disabled, or absent registration status. */ async getWalletRegistrationStatus({ + customerId, address, - }: { - address: string; - }): Promise { + }: GetWalletRegistrationStatusParams): Promise { return await this.#walletRegistrationService.getRegistrationStatus({ + customerId, address, blockchain: 'Monad', }); @@ -728,6 +756,29 @@ export class KycService extends BaseDataService< return bearerToken; } + /** + * Resolves the MetaMask canonical profile id used as MoonPay's partner + * `external_id` for neobank customer lookup. + * + * @returns Canonical profile id. + */ + async #getCanonicalExternalId(): Promise { + const profile = await this.messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', + ); + } + return externalId; + } + /** * Performs a single JSON request. * diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index f0deafb95b0..60e8e0d904f 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -46,6 +46,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + GetWalletRegistrationStatusParams, RegisterSelfHostedWalletParams, UkycSessionResponse, WrappedEncryptionKey, diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts index a8ee7540d23..c3be35aedeb 100644 --- a/packages/kyc-controller/src/wallet-registration-service.test.ts +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -1,10 +1,14 @@ import { + createIdempotencyKey, + extractErrorBody, WalletRegistrationError, WalletRegistrationService, } from './wallet-registration-service.js'; -const BASE_URL = 'https://proxy.metamask.test'; +const BASE_URL = 'https://on-ramp.dev-api.cx.metamask.io'; const AUTH_TOKEN = 'session-jwt-abc'; +const EXTERNAL_ID = 'canonical-profile-1'; +const CUSTOMER_ID = '019ff69c-3039-77b0-9d5d-e4a3baefd7b7'; type FetchInit = { method?: string; @@ -33,7 +37,8 @@ const jsonResponse = (status: number, body: unknown): HttpResponse => ({ ok: status >= 200 && status < 300, status, json: async (): Promise => body, - text: async (): Promise => JSON.stringify(body), + text: async (): Promise => + typeof body === 'string' ? body : JSON.stringify(body), }); const textResponse = (status: number, body: string): HttpResponse => ({ @@ -57,6 +62,7 @@ const buildService = (fetchImpl: FetchLike): WalletRegistrationService => fetch: fetchImpl, baseUrl: BASE_URL, getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => EXTERNAL_ID, }); const verifiedAddress = ( @@ -76,11 +82,73 @@ const verifiedAddress = ( const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; +describe('createIdempotencyKey', () => { + it('returns a non-empty string', () => { + expect(createIdempotencyKey().length).toBeGreaterThan(0); + }); + + it('falls back when randomUUID is unavailable', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: undefined }, + }); + try { + expect(createIdempotencyKey()).toMatch(/^wallet-reg-/u); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } + } + }); +}); + +describe('extractErrorBody', () => { + it('returns whitespace-only bodies unchanged', () => { + expect(extractErrorBody(' ')).toBe(' '); + expect(extractErrorBody('')).toBe(''); + }); + + it('unwraps a JSON-encoded string', () => { + expect(extractErrorBody(JSON.stringify('already exists'))).toBe( + 'already exists', + ); + }); + + it('prefers message on a JSON object', () => { + expect(extractErrorBody(JSON.stringify({ message: 'forbidden' }))).toBe( + 'forbidden', + ); + }); + + it('keeps a JSON object without message as raw text', () => { + const raw = JSON.stringify({ code: 'x', detail: 'nope' }); + expect(extractErrorBody(raw)).toBe(raw); + }); + + it('returns plain text that is not JSON', () => { + expect(extractErrorBody('not json at all')).toBe('not json at all'); + }); + + it('returns non-object JSON values as the raw trimmed text', () => { + expect(extractErrorBody('null')).toBe('null'); + expect(extractErrorBody('42')).toBe('42'); + expect(extractErrorBody('true')).toBe('true'); + }); +}); + describe('WalletRegistrationService.getMoonpayCustomerId', () => { - it('returns the Iron customer id from the authenticated proxy lookup', async () => { + it('returns Iron customer id from GET /neobank/customers/{external_id}/external', async () => { const fetchMock = jest.fn( async (): Promise => - jsonResponse(200, { customerId: 'iron-customer-1' }), + jsonResponse(200, { + id: 'iron-customer-1', + external_id: EXTERNAL_ID, + status: 'Active', + }), ); expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( @@ -88,7 +156,7 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); expect(fetchMock).toHaveBeenCalledWith( - `${BASE_URL}/vendors/moonpay/customer`, + `${BASE_URL}/neobank/customers/${EXTERNAL_ID}/external`, expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -98,15 +166,18 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); }); - it('maps a failed customer lookup to a typed HTTP error', async () => { + it('maps a failed customer lookup to a typed HTTP error with transparent body', async () => { const fetchMock = jest.fn( - async (): Promise => - jsonResponse(404, { code: 'iron_error', message: 'not found' }), + async (): Promise => textResponse(404, 'not found'), ); await expect( buildService(fetchMock).getMoonpayCustomerId(), - ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + ).rejects.toMatchObject({ + kind: 'notFound', + httpStatus: 404, + body: 'not found', + }); }); it('rejects malformed customer lookup responses', async () => { @@ -122,35 +193,59 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ).getMoonpayCustomerId(), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); + + it('rejects an empty external id before calling the network', async () => { + const fetchMock = jest.fn(); + const service = new WalletRegistrationService({ + fetch: fetchMock, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => '', + }); + + await expect(service.getMoonpayCustomerId()).rejects.toMatchObject({ + kind: 'malformedResponse', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe('WalletRegistrationService.getRegistrationStatus', () => { - it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + it('lists via /neobank/addresses/crypto/{customer_id}?filter=SelfHosted', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, []), ); const service = buildService(fetchMock); await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe( + `${BASE_URL}/neobank/addresses/crypto/${CUSTOMER_ID}?filter=SelfHosted`, + ); expect(url).not.toContain('iron.xyz'); + expect(url).not.toContain('/vendors/moonpay/'); expect(init.method).toBe('GET'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); }); - it('returns an active match parsed from wallet_address', async () => { + it('returns an active match parsed from wallet_address (Monad filter client-side)', async () => { const fetchMock = jest.fn( - async (): Promise => jsonResponse(200, [verifiedAddress()]), + async (): Promise => + jsonResponse(200, [ + verifiedAddress({ blockchain: 'Ethereum' }), + verifiedAddress(), + ]), ); const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: '0xabc0000000000000000000000000000000000001', blockchain: 'Monad', }); @@ -169,6 +264,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -184,6 +280,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -202,6 +299,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -217,6 +315,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -231,10 +330,11 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), - ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + ).rejects.toMatchObject({ kind: 'lookupUnavailable', body: 'boom' }); }); it('throws a lookupUnavailable error when the list body is malformed', async () => { @@ -245,6 +345,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -259,6 +360,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -273,6 +375,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -281,7 +384,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { }); const registerRequest = { - customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad' as const, message: 'I am verifying ownership ...', @@ -293,35 +396,33 @@ const selfHostedResponse = ( ): Record => ({ id: 'wallet-1', address: EVM_ADDRESS, - customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customer_id: CUSTOMER_ID, disabled: false, signature: '0xdeadbeef', created_at: '2026-08-12T10:00:00Z', ...overrides, }); -const errorEnvelope = (status: number, message: string): HttpResponse => - jsonResponse(status, { - code: 'iron_error', - message, - }); - describe('WalletRegistrationService.registerSelfHostedWallet', () => { - it('sends the five contract fields via POST and returns registered on 200', async () => { + it('posts to /neobank/addresses/crypto/selfhosted with an idempotency key', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), ); const service = buildService(fetchMock); - const outcome = await service.registerSelfHostedWallet(registerRequest); + const outcome = await service.registerSelfHostedWallet({ + ...registerRequest, + idempotencyKey: 'idem-wallet-1', + }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe(`${BASE_URL}/neobank/addresses/crypto/selfhosted`); expect(url).not.toContain('iron.xyz'); expect(init.method).toBe('POST'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(init.headers['Idempotency-Key']).toBe('idem-wallet-1'); expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ customer_id: registerRequest.customerId, address: registerRequest.address, @@ -336,7 +437,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { }); }); - it('does not send an idempotency key (the backend derives it)', async () => { + it('generates an Idempotency-Key when the caller omits one', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), @@ -346,17 +447,13 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await service.registerSelfHostedWallet(registerRequest); const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - const headerKeys = Object.keys(init.headers).map((key) => - key.toLowerCase(), - ); - expect(headerKeys).not.toContain('idempotency-key'); - expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + expect(init.headers['Idempotency-Key']?.length).toBeGreaterThan(0); }); - it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + it('maps a plain-string 409 body to an ambiguous conflict error', async () => { const fetchMock = jest.fn( async (): Promise => - errorEnvelope( + textResponse( 409, 'A crypto address with this wallet address already exists', ), @@ -374,7 +471,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 5xx to a transient error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(500, 'internal error'), + async (): Promise => textResponse(500, 'internal error'), ); const service = buildService(fetchMock); @@ -407,7 +504,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 400 to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(400, 'bad message'), + async (): Promise => textResponse(400, 'bad message'), ); const service = buildService(fetchMock); @@ -418,7 +515,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps an unmapped 4xx (422) to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(422, 'unprocessable'), + async (): Promise => textResponse(422, 'unprocessable'), ); const service = buildService(fetchMock); @@ -429,7 +526,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 401 to unauthorized', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(401, 'session expired'), + async (): Promise => textResponse(401, 'session expired'), ); await expect( @@ -439,10 +536,10 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 403 to forbidden and 404 to notFound', async () => { const forbiddenFetch = jest.fn( - async (): Promise => errorEnvelope(403, 'suspended'), + async (): Promise => textResponse(403, 'suspended'), ); const notFoundFetch = jest.fn( - async (): Promise => errorEnvelope(404, 'not found'), + async (): Promise => textResponse(404, 'not found'), ); const forbidden = await buildService(forbiddenFetch) @@ -460,7 +557,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { expect(notFound).toMatchObject({ kind: 'notFound' }); }); - it('maps an error envelope without a code', async () => { + it('maps a JSON error object with message when present', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(403, { message: 'forbidden' }), @@ -469,12 +566,80 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await expect( service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'forbidden' }); + ).rejects.toMatchObject({ kind: 'forbidden', body: 'forbidden' }); + }); + + it('maps a JSON-encoded string error body', async () => { + const fetchMock = jest.fn( + async (): Promise => + textResponse(409, JSON.stringify('already exists')), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'conflict', body: 'already exists' }); + }); + + it('keeps a JSON object without message as the raw body', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(400, { code: 'x', detail: 'nope' }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: JSON.stringify({ code: 'x', detail: 'nope' }), + }); + }); + + it('keeps a whitespace-only error body as-is', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ' '), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', body: ' ' }); + }); + + it('omits Error.message when the upstream body is empty', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ''), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: '', + message: 'wallet registration failed: validation', + }); + }); + + it('maps an unreadable error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => ({ + ok: false, + status: 500, + json: async (): Promise => { + throw new Error('no json'); + }, + text: async (): Promise => { + throw new Error('no text'); + }, + }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse', httpStatus: 500 }); }); it('maps 429 to a rateLimited error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(429, 'slow down'), + async (): Promise => textResponse(429, 'slow down'), ); const service = buildService(fetchMock); @@ -516,15 +681,4 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { service.registerSelfHostedWallet(registerRequest), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); - - it('maps a non-JSON error body to malformedResponse', async () => { - const fetchMock = jest.fn( - async (): Promise => invalidJsonResponse(400), - ); - const service = buildService(fetchMock); - - await expect( - service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'malformedResponse' }); - }); }); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts index 08d118d3b8c..240f6720474 100644 --- a/packages/kyc-controller/src/wallet-registration-service.ts +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -75,11 +75,21 @@ export class WalletRegistrationError extends Error { export type WalletRegistrationServiceOptions = { fetch: FetchLike; + /** + * Base URL of the Money Movement neobank-proxy host + * (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are under `/neobank`. + */ baseUrl: string; getAuthToken: () => Promise; + /** + * MetaMask profile / partner external id used as MoonPay `external_id` + * (typically `AuthenticationController:getSessionProfile().canonicalProfileId`). + */ + getExternalId: () => Promise; }; export type GetRegistrationStatusRequest = { + customerId: string; address: string; blockchain: Blockchain; }; @@ -90,6 +100,11 @@ export type RegisterSelfHostedWalletRequest = { blockchain: Blockchain; message: string; signature: string; + /** + * Stable key reused across retries of the same ownership proof. Generated + * when omitted. + */ + idempotencyKey?: string; }; /** Successful registration outcome. */ @@ -98,9 +113,6 @@ export type RegistrationOutcome = { registration: SelfHostedRegistration; }; -const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; -const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; - /** * Normalizes a Monad EVM address for case-insensitive comparison. * @@ -137,7 +149,53 @@ function mapStatusToKind(status: number): WalletRegistrationErrorKind { } /** - * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * Builds a client-side Idempotency-Key for MoonPay POSTs. Prefer a stable + * caller-supplied key across retries of the same proof. + * + * @returns A random UUID when available, otherwise a timestamped fallback. + */ +export function createIdempotencyKey(): string { + const cryptoObj = globalThis.crypto as + | { randomUUID?: () => string } + | undefined; + if (typeof cryptoObj?.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `wallet-reg-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Extracts a human-readable error body from a transparent neobank-proxy + * response. Upstream may return a plain string or a JSON value; both are + * mirrored 1:1 (no `{ code: 'iron_error' }` envelope). + * + * @param raw - Raw response text. + * @returns Normalized body string for {@link WalletRegistrationError}. + */ +export function extractErrorBody(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return raw; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === 'string') { + return parsed; + } + if (parsed && typeof parsed === 'object') { + const { message } = parsed as { message?: unknown }; + if (typeof message === 'string') { + return message; + } + } + return trimmed; + } catch { + return trimmed; + } +} + +/** + * Data service that talks to the Money Movement neobank-proxy for MoonPay Iron * self-hosted wallet registration. It never calls Iron directly, so the Iron * API key never ships in the client. */ @@ -148,23 +206,36 @@ export class WalletRegistrationService { readonly #getAuthToken: () => Promise; + readonly #getExternalId: () => Promise; + constructor(options: WalletRegistrationServiceOptions) { this.#fetch = options.fetch; this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); this.#getAuthToken = options.getAuthToken; + this.#getExternalId = options.getExternalId; } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. Used when the current KYC flow has not already received - * `customer.id` from MoonPay's hosted frame. + * Resolves Iron's internal customer id via + * `GET /neobank/customers/{external_id}/external`, using the MetaMask + * profile/canonical id as `external_id`. Used when the current KYC flow has + * not already received `customer.id` from MoonPay's hosted frame. * * @returns Iron's internal customer id. */ async getMoonpayCustomerId(): Promise { - const token = await this.#getAuthToken(); + const [token, externalId] = await Promise.all([ + this.#getAuthToken(), + this.#getExternalId(), + ]); + if (!externalId) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MetaMask external id (canonical profile id) is empty', + }); + } + const response = await this.#fetch( - `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + `${this.#baseUrl}/neobank/customers/${encodeURIComponent(externalId)}/external`, { method: 'GET', headers: { @@ -187,32 +258,38 @@ export class WalletRegistrationService { }); } - const { customerId } = payload as { customerId?: unknown }; - if (typeof customerId !== 'string' || customerId.length === 0) { + const { id } = payload as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { throw new WalletRegistrationError('malformedResponse', { - message: 'MoonPay customer body missing customerId', + message: 'MoonPay customer body missing id', }); } - return customerId; + return id; } /** - * Reconciles a wallet against the customer's registered self-hosted addresses. - * A failed or malformed lookup is reported as `lookupUnavailable` and never - * downgraded to `absent`. + * Reconciles a wallet against the customer's registered self-hosted addresses + * via `GET /neobank/addresses/crypto/{customer_id}?filter=SelfHosted`. + * Upstream returns all self-hosted chains; Monad filtering stays client-side + * for the POC. A failed or malformed lookup is reported as + * `lookupUnavailable` and never downgraded to `absent`. * - * @param request - Monad address to reconcile. + * @param request - Customer id and Monad address to reconcile. * @returns The active / disabled / absent status for the address. */ async getRegistrationStatus( request: GetRegistrationStatusRequest, ): Promise { - const { address, blockchain } = request; + const { customerId, address, blockchain } = request; let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + const url = new URL( + `${this.#baseUrl}/neobank/addresses/crypto/${encodeURIComponent(customerId)}`, + ); + url.searchParams.set('filter', 'SelfHosted'); + response = await this.#fetch(url.toString(), { method: 'GET', headers: { accept: 'application/json', @@ -230,7 +307,7 @@ export class WalletRegistrationService { const body = await response.text(); throw new WalletRegistrationError('lookupUnavailable', { httpStatus: response.status, - body, + body: extractErrorBody(body), }); } @@ -272,11 +349,12 @@ export class WalletRegistrationService { } /** - * Registers a self-hosted wallet through the MetaMask proxy. The proxy - * resolves the customer, derives the idempotency key, and attaches the API - * version, so the client never manages those. Every non-2xx response is - * mapped to a typed error; `409` is deliberately surfaced as an ambiguous - * `conflict` that the caller must reconcile with a follow-up status lookup. + * Registers a self-hosted wallet through neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. The client supplies + * `customer_id` and an `Idempotency-Key` (generated when omitted). Every + * non-2xx response is mapped to a typed error; `409` is deliberately + * surfaced as an ambiguous `conflict` that the caller must reconcile with a + * follow-up status lookup. * * @param request - Customer id, address, blockchain, message, and signature. * @returns The registered outcome on success. @@ -284,24 +362,29 @@ export class WalletRegistrationService { async registerSelfHostedWallet( request: RegisterSelfHostedWalletRequest, ): Promise { + const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey(); let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - authorization: `Bearer ${token}`, + response = await this.#fetch( + `${this.#baseUrl}/neobank/addresses/crypto/selfhosted`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), }, - body: JSON.stringify({ - customer_id: request.customerId, - address: request.address, - blockchain: request.blockchain, - message: request.message, - signature: request.signature, - }), - }); + ); } catch (error) { throw new WalletRegistrationError('transient', { message: 'self-hosted registration request failed', @@ -342,21 +425,23 @@ export class WalletRegistrationService { } async #toHttpError(response: HttpResponse): Promise { - let envelope: { message?: string }; + let raw = ''; try { - envelope = (await response.json()) as { message?: string }; + raw = await response.text(); } catch { return new WalletRegistrationError('malformedResponse', { httpStatus: response.status, - message: 'error body was not valid JSON', + message: 'error body could not be read', }); } const { status } = response; const kind = mapStatusToKind(status); + const body = extractErrorBody(raw); return new WalletRegistrationError(kind, { httpStatus: status, - body: envelope.message, + body, + message: body || undefined, }); } From bd674c3ebf3457a580bcd27b81046a550a8083b3 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 14:52:45 -0600 Subject: [PATCH 55/67] fix(transaction-pay-controller): do not cache skipped vault deposits Retain successful deposit results for controller-lifetime dedupe, but clear skipped results so enabling vaulting later can retry the same payout hash. --- .../src/TransactionPayController.test.ts | 19 ++++++++++++++++++ .../src/TransactionPayController.ts | 20 +++++++++++++------ 2 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index 63c20eb62f3..c708945641d 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -204,6 +204,25 @@ describe('TransactionPayController', () => { ).toHaveBeenCalledTimes(2); }); + it('retries after a skipped deposit once vaulting is enabled', async () => { + submitMoneyAccountVaultDepositFromPayoutMock + .mockResolvedValueOnce({ skipped: true }) + .mockResolvedValueOnce({ transactionHash }); + const controller = createController(); + const request = { moneyAccountAddress, transactionHash }; + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ skipped: true }); + + await expect( + controller.submitMoneyAccountVaultDeposit(request), + ).resolves.toStrictEqual({ transactionHash }); + expect( + submitMoneyAccountVaultDepositFromPayoutMock, + ).toHaveBeenCalledTimes(2); + }); + it('exposes the exact-out withdraw action through the messenger', async () => { submitMoneyAccountVaultWithdrawUtilMock.mockResolvedValue({ batchId: '0x123' as Hex, diff --git a/packages/transaction-pay-controller/src/TransactionPayController.ts b/packages/transaction-pay-controller/src/TransactionPayController.ts index 0687182d265..aa6c777a860 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.ts @@ -247,8 +247,9 @@ export class TransactionPayController extends BaseController< * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. - * Successful results are retained so retries return the prior hash without - * submitting again. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. * * @param request - Completed Iron payout details. * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` @@ -266,10 +267,17 @@ export class TransactionPayController extends BaseController< const pending = submitMoneyAccountVaultDepositFromPayout( request, this.messenger, - ).catch((error: unknown) => { - this.#vaultDepositRequests.delete(key); - throw error; - }); + ) + .then((result) => { + if (result.skipped) { + this.#vaultDepositRequests.delete(key); + } + return result; + }) + .catch((error: unknown) => { + this.#vaultDepositRequests.delete(key); + throw error; + }); this.#vaultDepositRequests.set(key, pending); return pending; } From a40fa2fc3fed565aa8e9d5a961628accc60565df Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 12 Aug 2026 15:17:45 -0600 Subject: [PATCH 56/67] feat(kyc-controller): add Iron Money KYC path and status polling Introduce vendor:'iron' flow (no MoonPay Check/Auth frames) with KycService clients for Iron customers/disclaimers/consents and user-keyed GET /kyc/status, so Money can drive toast state against the planned Milestone 1 API contract. --- packages/kyc-controller/CHANGELOG.md | 1 + .../src/KycController-method-action-types.ts | 33 + .../kyc-controller/src/KycController.test.ts | 696 ++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 424 ++++++++++- .../src/KycService-method-action-types.ts | 65 ++ .../kyc-controller/src/KycService.test.ts | 270 +++++++ packages/kyc-controller/src/KycService.ts | 220 +++++- packages/kyc-controller/src/index.ts | 13 + packages/kyc-controller/src/types.ts | 50 +- 9 files changed, 1747 insertions(+), 25 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 71dc41e7c9f..51617299f34 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 2586ea9c637..a6b747d88ca 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -16,12 +16,27 @@ import type { KycController } from './KycController.js'; * authentication completes (and chains into document verification when KYC * is required). When omitted, the flow stops at `form` and the consumer must * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Pass `iron` for the + * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`. */ export type KycControllerInitializeAction = { type: `KycController:initialize`; handler: KycController['initialize']; }; +/** + * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can + * ensure the customer exists before showing T&C screens independently of + * {@link initialize}. + * + * @param params - The parameters. + * @param params.email - Email for the Iron customer. + */ +export type KycControllerCreateIronCustomerAction = { + type: `KycController:createIronCustomer`; + handler: KycController['createIronCustomer']; +}; + /** * Loads the disclaimers for the resolved (or provided) country. * @@ -42,6 +57,10 @@ export type KycControllerLoadDisclaimersAction = { * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. + * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were + * accepted (T&C2). Defaults to `true` when omitted. + * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted + * (T&C2). Defaults to `true` when omitted. */ export type KycControllerAcceptTermsAndStartSessionAction = { type: `KycController:acceptTermsAndStartSession`; @@ -154,6 +173,18 @@ export type KycControllerStartSumSubAction = { handler: KycController['startSumSub']; }; +/** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ +export type KycControllerRefreshKycStatusAction = { + type: `KycController:refreshKycStatus`; + handler: KycController['refreshKycStatus']; +}; + /** * Fetches the current UKYC session status for the active sub-flow and records * it on state. Useful for a one-off refresh outside the automatic polling @@ -181,6 +212,7 @@ export type KycControllerResetAction = { */ export type KycControllerMethodActions = | KycControllerInitializeAction + | KycControllerCreateIronCustomerAction | KycControllerLoadDisclaimersAction | KycControllerAcceptTermsAndStartSessionAction | KycControllerClearSavedTermsAction @@ -191,5 +223,6 @@ export type KycControllerMethodActions = | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction | KycControllerStartSumSubAction + | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 3c3078c27e3..c74b32b7f6a 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1769,6 +1769,663 @@ describe('KycController', () => { }); }); + describe('iron vendor flow', () => { + afterEach(() => { + jest.clearAllTimers(); + jest.useRealTimers(); + }); + + it('creates an Iron customer and loads Iron disclaimers on initialize', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchIronDisclaimers.mockResolvedValue([ + { id: 'd1', display_name: 'Iron T&C', url: 'https://t' }, + ]); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.createIronCustomer).toHaveBeenCalledWith({ + email: 'a@b.co', + }); + expect(handlers.fetchIronDisclaimers).toHaveBeenCalledWith({ + country: 'USA', + }); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.activeProduct).toBe('money'); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimers).toHaveLength(1); + }); + }); + + it('fails initialize when Iron customer creation fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createIronCustomer.mockRejectedValue(new Error('iron down')); + + await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Iron customer creation failed/u); + }); + }); + + it('does not fail initialize when reset lands during Iron customer creation', async () => { + await withController(async ({ controller, handlers }) => { + let release: (value: { + id: string; + email: string; + status: string; + }) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + }); + controller.reset(); + release({ id: '1', email: 'a@b.co', status: 'SigningsRequired' }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('does not fail initialize when Iron customer creation rejects after reset', async () => { + await withController(async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + }); + controller.reset(); + release(new Error('late')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('resumes an Iron session when terms and email are already present', async () => { + await withController( + { + options: { + state: { + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['d1'], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.initialize({ + email: 'a@b.co', + vendor: 'iron', + product: 'money', + }); + + expect(handlers.submitConsents).toHaveBeenCalled(); + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('done'); + controller.reset(); + }, + ); + }); + + it('createIronCustomer sets the vendor and fails on API errors', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createIronCustomer.mockRejectedValue(new Error('nope')); + + await controller.createIronCustomer({ email: 'a@b.co' }); + + expect(controller.state.activeVendor).toBe('iron'); + expect(controller.state.email).toBe('a@b.co'); + expect(controller.state.phase).toBe('error'); + }); + }); + + it('createIronCustomer ignores API errors after reset', async () => { + await withController(async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.createIronCustomer.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.createIronCustomer({ email: 'a@b.co' }); + controller.reset(); + release(new Error('late')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }); + }); + + it('posts consents and starts SumSub without MoonPay frames', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.submitConsents.mockResolvedValue(undefined); + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'money', + sumsubTncSigned: true, + idosTncSigned: true, + }); + + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.submitConsents).toHaveBeenCalledWith({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }); + expect(handlers.createUkycSession).toHaveBeenCalledWith( + expect.objectContaining({ vendorId: 'iron' }), + ); + expect(launcher.launch).toHaveBeenCalled(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.userStatus).toBe('pending'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + controller.reset(); + }, + ); + }); + + it('fails the Iron session when email is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails the Iron session when disclaimer acceptance is missing', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + email: 'a@b.co', + disclaimers: [], + }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing Iron disclaimer/u); + }, + ); + }); + + it('returns to terms when SumSub fails during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue(new Error('sumsub down')); + handlers.fetchIronDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Iron session failed/u); + }, + ); + }); + + it('keeps done when status refresh fails after a successful SumSub', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + handlers.fetchKycStatus.mockRejectedValue(new Error('status down')); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + controller.reset(); + }, + ); + }); + + it('ignores in-flight Iron consents after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: () => void = () => { + // placeholder + }; + handlers.submitConsents.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + controller.reset(); + release(); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.createUkycSession).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores SumSub completion after reset during the Iron session', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers, launcher }) => { + let releaseLaunch: (value: { ok: boolean }) => void = () => { + // placeholder + }; + launcher.launch.mockReturnValue( + new Promise((resolve) => { + releaseLaunch = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + // Consents + UKYC session run first; wait until launch is pending. + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + releaseLaunch({ ok: true }); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(handlers.fetchKycStatus).not.toHaveBeenCalled(); + }, + ); + }); + + it('ignores Iron session failures after reset', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.submitConsents.mockReturnValue( + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession({ + email: 'a@b.co', + }); + controller.reset(); + release(new Error('late consent failure')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('refreshKycStatus stores status and emits statusChanged', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers, rootMessenger }) => { + const listener = jest.fn(); + rootMessenger.subscribe('KycController:statusChanged', listener); + handlers.fetchKycStatus.mockResolvedValue({ + status: 'completed', + sumsubSessionId: 'ss-1', + }); + + const result = await controller.refreshKycStatus(); + + expect(result).toStrictEqual({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + expect(controller.state.userStatus).toBe('completed'); + expect(listener).toHaveBeenCalledWith({ + status: 'completed', + sumsubSessionId: 'ss-1', + errorCode: null, + }); + }, + ); + }); + + it('polls user status while pending and stops on a terminal status', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'pending' }) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + expect(controller.state.userStatus).toBe('pending'); + + // First tick stays pending and reschedules; second tick completes. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('pending'); + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.userStatus).toBe('completed'); + + // A second refresh while pending would no-op the timer start; then + // reset clears any leftover handles. + handlers.fetchKycStatus.mockResolvedValue({ status: 'pending' }); + await controller.refreshKycStatus(); + await controller.refreshKycStatus(); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status poll ticks after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((resolve) => { + release = resolve; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release({ status: 'completed' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('keeps polling when a user-status tick fails transiently', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockRejectedValueOnce(new Error('transient')) + .mockResolvedValueOnce({ status: 'completed' }); + + await controller.refreshKycStatus(); + await jest.advanceTimersByTimeAsync(1000); + await jest.advanceTimersByTimeAsync(1000); + + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('drops superseded user-status ticks that fail after reset', async () => { + jest.useFakeTimers(); + try { + await withController( + { options: { userStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers }) => { + let release: (error: Error) => void = () => { + // placeholder + }; + handlers.fetchKycStatus + .mockResolvedValueOnce({ status: 'pending' }) + .mockImplementationOnce( + async () => + new Promise((_resolve, reject) => { + release = reject; + }), + ); + + await controller.refreshKycStatus(); + jest.advanceTimersByTime(1000); + await Promise.resolve(); + await Promise.resolve(); + controller.reset(); + release(new Error('late')); + await Promise.resolve(); + await Promise.resolve(); + + expect(controller.state.userStatus).toBe('pending'); + }, + ); + } finally { + jest.useRealTimers(); + } + }); + + it('returns cached user status when reset lands during refresh', async () => { + await withController( + { + options: { + state: { userStatus: 'pending' }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('pending'); + }, + ); + }); + + it('defaults superseded refresh status to not-started when unset', async () => { + await withController( + { options: { userStatusPollIntervalMs: 60_000 } }, + async ({ controller, handlers }) => { + let release: (value: { status: string }) => void = () => { + // placeholder + }; + handlers.fetchKycStatus.mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + + const pending = controller.refreshKycStatus(); + controller.reset(); + release({ status: 'completed' }); + const result = await pending; + + expect(result.status).toBe('not-started'); + }, + ); + }); + + it('maps session_not_in_valid_state to completed during SumSub', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit' }, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error( + "Fetching 'https://x' failed with status '409': session_not_in_valid_state", + ), + ); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBe('completed'); + expect(controller.state.phase).toBe('done'); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('keeps phase done when Iron SumSub reports already completed', async () => { + await withController( + { + options: { + state: { + activeVendor: 'iron', + disclaimers: [{ id: 'd1', display_name: 'T', url: 'u' }], + }, + userStatusPollIntervalMs: 60_000, + }, + }, + async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue( + new Error('session_not_in_valid_state'), + ); + handlers.fetchKycStatus.mockResolvedValue({ status: 'completed' }); + + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('done'); + expect(controller.state.userStatus).toBe('completed'); + controller.reset(); + }, + ); + }); + }); + describe('messenger actions', () => { it('exposes methods as messenger actions', async () => { await withController(({ rootMessenger }) => { @@ -1791,6 +2448,11 @@ type ServiceHandlers = { fetchDisclaimers: jest.Mock; createSession: jest.Mock; checkKycRequired: jest.Mock; + createIronCustomer: jest.Mock; + fetchIronDisclaimers: jest.Mock; + checkIronKycRequired: jest.Mock; + submitConsents: jest.Mock; + fetchKycStatus: jest.Mock; getWrappingKey: jest.Mock; fetchJwks: jest.Mock; createUkycSession: jest.Mock; @@ -1821,6 +2483,11 @@ const SERVICE_ACTIONS = [ 'KycService:fetchDisclaimers', 'KycService:createSession', 'KycService:checkKycRequired', + 'KycService:createIronCustomer', + 'KycService:fetchIronDisclaimers', + 'KycService:checkIronKycRequired', + 'KycService:submitConsents', + 'KycService:fetchKycStatus', 'KycService:getWrappingKey', 'KycService:fetchJwks', 'KycService:createUkycSession', @@ -1886,6 +2553,15 @@ function withController( fetchDisclaimers: jest.fn().mockResolvedValue([]), createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + createIronCustomer: jest.fn().mockResolvedValue({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }), + fetchIronDisclaimers: jest.fn().mockResolvedValue([]), + checkIronKycRequired: jest.fn().mockResolvedValue({ kycRequired: true }), + submitConsents: jest.fn().mockResolvedValue(undefined), + fetchKycStatus: jest.fn().mockResolvedValue({ status: 'pending' }), getWrappingKey: jest.fn().mockResolvedValue({ id: 'wk', jwtChain: 'jwt.chain.sig', @@ -1920,6 +2596,26 @@ function withController( 'KycService:checkKycRequired', handlers.checkKycRequired, ); + rootMessenger.registerActionHandler( + 'KycService:createIronCustomer', + handlers.createIronCustomer, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchIronDisclaimers', + handlers.fetchIronDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:checkIronKycRequired', + handlers.checkIronKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:submitConsents', + handlers.submitConsents, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchKycStatus', + handlers.fetchKycStatus, + ); rootMessenger.registerActionHandler( 'KycService:getWrappingKey', handlers.getWrappingKey, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 3abb1eb9dcf..242072501ad 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -23,6 +23,8 @@ import type { KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, + KycUserStatus, + KycVendor, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; import { toBase64Url } from './encoding.js'; @@ -110,6 +112,14 @@ const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ const VENDOR_PROCESSING_MESSAGE = 'Your KYC has been submitted and is being processed by the vendor.'; +// UKYC / relay error indicating the applicant already finished KYC. Mapped to +// the simplified `completed` user status for the Money toast surface. +const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state'; + +// How often to refresh the user-keyed `GET /kyc/status` while the simplified +// status is still `pending`. Overridable via the constructor. +const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000; + // === STATE === /** @@ -146,6 +156,13 @@ export type KycControllerState = { /** Vendor customer id, used for the SumSub hand-off. */ moonpayCustomerId: string | null; + /** + * The identity vendor driving the current flow. Captured at `initialize`. + * Defaults to `moonpay` when omitted so existing ramps/card callers keep + * the Check/Auth frame path. `iron` skips those frames. + */ + activeVendor: KycVendor; + /** * The product the current flow is running for. Captured at `initialize` * (or `acceptTermsAndStartSession`) and used to automatically run the @@ -160,6 +177,17 @@ export type KycControllerState = { /** ISO-8601 timestamp of the last KYC-required check (persisted). */ lastCheckedAt: string | null; + /** + * User-keyed simplified KYC status from `GET /kyc/status` (persisted so the + * Money toast can render across cold starts). `null` until the first + * successful `refreshKycStatus`. + */ + userStatus: KycUserStatus | null; + /** Optional SumSub session id for the retryable error path. */ + userStatusSumsubSessionId: string | null; + /** Optional machine-readable error code for terminal / EDD UX. */ + userStatusErrorCode: string | null; + /** SumSub document-verification sub-flow state. */ sumsub: { status: KycSumSubStatus; @@ -247,6 +275,12 @@ const kycControllerMetadata = { persist: false, usedInUi: false, }, + activeVendor: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, activeProduct: { includeInDebugSnapshot: true, includeInStateLogs: true, @@ -265,6 +299,24 @@ const kycControllerMetadata = { persist: true, usedInUi: false, }, + userStatus: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + userStatusSumsubSessionId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: true, + usedInUi: true, + }, + userStatusErrorCode: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, sumsub: { includeInDebugSnapshot: false, includeInStateLogs: false, @@ -292,9 +344,13 @@ export function getDefaultKycControllerState(): KycControllerState { sessionToken: null, accessToken: null, moonpayCustomerId: null, + activeVendor: 'moonpay', activeProduct: null, kycRequiredByProduct: {}, lastCheckedAt: null, + userStatus: null, + userStatusSumsubSessionId: null, + userStatusErrorCode: null, sumsub: { status: 'idle', result: null, @@ -311,6 +367,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'initialize', 'loadDisclaimers', 'acceptTermsAndStartSession', + 'createIronCustomer', 'clearSavedTerms', 'handleFrameMessage', 'buildCheckFrameUrl', @@ -318,6 +375,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'refreshKycStatus', 'startSumSub', 'getSessionStatus', 'reset', @@ -342,7 +400,23 @@ export type KycControllerStateChangeEvent = ControllerStateChangeEvent< KycControllerState >; -export type KycControllerEvents = KycControllerStateChangeEvent; +/** + * Published when the user-keyed simplified KYC status changes (Money toast). + */ +export type KycControllerStatusChangedEvent = { + type: `${typeof controllerName}:statusChanged`; + payload: [ + { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }, + ]; +}; + +export type KycControllerEvents = + | KycControllerStateChangeEvent + | KycControllerStatusChangedEvent; type AllowedEvents = never; @@ -369,6 +443,12 @@ export type KycControllerOptions = { * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. */ sessionStatusPollIntervalMs?: number; + /** + * How often, in milliseconds, to refresh `GET /kyc/status` while the + * simplified user status is `pending`. Defaults to + * {@link DEFAULT_USER_STATUS_POLL_INTERVAL_MS}. + */ + userStatusPollIntervalMs?: number; }; /** @@ -434,6 +514,15 @@ export class KycController extends BaseController< */ #pollToken = 0; + /** Interval, in milliseconds, between user-keyed status polls. */ + readonly #userStatusPollIntervalMs: number; + + /** Handle for the scheduled next user-status poll, or `null`. */ + #userStatusPollTimer: ReturnType | null = null; + + /** Monotonic token for the user-status poll loop (see `#pollToken`). */ + #userStatusPollToken = 0; + /** * Constructs a new {@link KycController}. * @@ -443,12 +532,15 @@ export class KycController extends BaseController< * @param options.sumsubLauncher - The platform SumSub launcher adapter. * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC * session status after the SumSub SDK completes. + * @param options.userStatusPollIntervalMs - How often to refresh the + * user-keyed KYC status while it is still `pending`. */ constructor({ messenger, state, sumsubLauncher, sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS, }: KycControllerOptions) { super({ messenger, @@ -459,6 +551,7 @@ export class KycController extends BaseController< this.#sumsubLauncher = sumsubLauncher; this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; + this.#userStatusPollIntervalMs = userStatusPollIntervalMs; this.#keypair = generateKeyPair(); this.messenger.registerMethodActionHandlers( @@ -510,10 +603,13 @@ export class KycController extends BaseController< * authentication completes (and chains into document verification when KYC * is required). When omitted, the flow stops at `form` and the consumer must * call `checkKycRequired` manually. + * @param params.vendor - Identity vendor for this flow. Pass `iron` for the + * Money/VBA path (no MoonPay Check/Auth frames). Defaults to `moonpay`. */ async initialize(params?: { email?: string; product?: KycProduct; + vendor?: KycVendor; }): Promise { // A repeat `initialize` while a session flow is already in progress must // not tear it down: creating a new vendor session clears the tokens and @@ -524,6 +620,8 @@ export class KycController extends BaseController< return; } + const vendor = params?.vendor ?? 'moonpay'; + // `initialize` starts a fresh flow, so `activeProduct` is always reset to // this call's product (or `null`). Otherwise a prior run's product could // linger and cause `#continueAfterAuthentication` to auto-run the check / @@ -532,6 +630,7 @@ export class KycController extends BaseController< if (params?.email) { state.email = params.email; } + state.activeVendor = vendor; state.activeProduct = params?.product ?? null; }); @@ -550,12 +649,37 @@ export class KycController extends BaseController< // Ignore; disclaimers loading will surface a country error if needed. } + // Iron: create the empty-shell customer before T&C (offsite decision). + if (vendor === 'iron' && this.state.email) { + try { + await this.messenger.call('KycService:createIronCustomer', { + email: this.state.email, + }); + if (this.#generation !== generation) { + return; + } + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Iron customer creation failed: ${String(error)}`); + return; + } + } + const hasTerms = Boolean(this.state.termsAcceptedAt) && this.state.acceptedDisclaimerIds.length > 0; if (hasTerms && this.state.email) { - await this.#createSession(); + if (vendor === 'iron') { + await this.#startIronSession({ + sumsubTncSigned: true, + idosTncSigned: true, + }); + } else { + await this.#createSession(); + } return; } @@ -565,6 +689,32 @@ export class KycController extends BaseController< await this.loadDisclaimers(); } + /** + * Creates (or resumes) an Iron empty-shell customer. Exposed so Money can + * ensure the customer exists before showing T&C screens independently of + * {@link initialize}. + * + * @param params - The parameters. + * @param params.email - Email for the Iron customer. + */ + async createIronCustomer(params: { email: string }): Promise { + this.#applyUpdate((state) => { + state.email = params.email; + state.activeVendor = 'iron'; + }); + const generation = this.#generation; + try { + await this.messenger.call('KycService:createIronCustomer', { + email: params.email, + }); + } catch (error) { + if (this.#generation !== generation) { + return; + } + this.#fail(`Iron customer creation failed: ${String(error)}`); + } + } + /** * Loads the disclaimers for the resolved (or provided) country. * @@ -586,10 +736,14 @@ export class KycController extends BaseController< state.geoCountry = country; }); } - const disclaimers = await this.messenger.call( - 'KycService:fetchDisclaimers', - { country }, - ); + const disclaimers = + this.state.activeVendor === 'iron' + ? await this.messenger.call('KycService:fetchIronDisclaimers', { + country, + }) + : await this.messenger.call('KycService:fetchDisclaimers', { + country, + }); this.#updateIfCurrent(generation, (state) => { state.disclaimers = disclaimers; state.disclaimersError = null; @@ -610,10 +764,16 @@ export class KycController extends BaseController< * @param params.product - The consuming feature the flow runs for. See * {@link initialize} for how the product drives the automatic post * authentication continuation. + * @param params.sumsubTncSigned - Iron path: whether Sumsub T&C were + * accepted (T&C2). Defaults to `true` when omitted. + * @param params.idosTncSigned - Iron path: whether idOS T&C were accepted + * (T&C2). Defaults to `true` when omitted. */ async acceptTermsAndStartSession(params?: { email?: string; product?: KycProduct; + sumsubTncSigned?: boolean; + idosTncSigned?: boolean; }): Promise { const termsAcceptedAt = new Date().toISOString(); const disclaimerIds = this.state.disclaimers.map( @@ -629,9 +789,101 @@ export class KycController extends BaseController< state.termsAcceptedAt = termsAcceptedAt; state.acceptedDisclaimerIds = disclaimerIds; }); + if (this.state.activeVendor === 'iron') { + await this.#startIronSession({ + sumsubTncSigned: params?.sumsubTncSigned ?? true, + idosTncSigned: params?.idosTncSigned ?? true, + }); + return; + } await this.#createSession(); } + /** + * Iron-only path: post consents (Iron signings + Sumsub/idOS ack), then + * launch SumSub — skipping MoonPay Check/Auth frames. + * + * @param consents - T&C2 boolean flags. + */ + async #startIronSession(consents: { + sumsubTncSigned: boolean; + idosTncSigned: boolean; + }): Promise { + const { email, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for Iron session.'); + return; + } + if (acceptedDisclaimerIds.length === 0) { + this.#fail('Missing Iron disclaimer acceptance.'); + return; + } + + const generation = this.#generation; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Submitting consents...'; + // Iron has no MoonPay session/access tokens. + state.sessionToken = null; + state.accessToken = null; + }); + + try { + await this.messenger.call('KycService:submitConsents', { + ironDisclaimerIds: acceptedDisclaimerIds, + sumsubTncSigned: consents.sumsubTncSigned, + idosTncSigned: consents.idosTncSigned, + }); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Starting document verification...'; + }); + const sumsubResult = await this.startSumSub(); + if (this.#generation !== generation) { + return; + } + if ( + sumsubResult && + 'error' in sumsubResult && + typeof sumsubResult.error === 'string' + ) { + throw new Error(sumsubResult.error); + } + // After SumSub, refresh user-keyed status for the Money toast and start + // polling while still pending. Soft-fail: toast refresh must not rewind + // the consent / SumSub outcome. + try { + await this.refreshKycStatus(); + } catch (statusError) { + console.error('KYC status refresh failed:', statusError); + } + this.#updateIfCurrent(generation, (state) => { + if (state.phase !== 'error' && state.phase !== 'done') { + state.phase = 'done'; + state.statusMessage = 'KYC submitted.'; + } + }); + } catch (error) { + console.error('Iron session failed:', error); + if (this.#generation !== generation) { + return; + } + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.error = `Iron session failed: ${String(error)}`; + state.statusMessage = + 'Consent / verification failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + /** * Creates a vendor session from the currently stored terms + email. */ @@ -1142,14 +1394,20 @@ export class KycController extends BaseController< expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS), }); + const isIron = this.state.activeVendor === 'iron'; const { sessionId, kycStatus, finalStatus } = await this.messenger.call( 'KycService:createUkycSession', { jwtToken, - vendorMetadata: { - moonPayAccessToken: this.state.accessToken, - moonPayUserId: this.state.moonpayCustomerId, - }, + vendorId: isIron ? 'iron' : 'moonpay', + ...(isIron + ? {} + : { + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }), wrappedEncryptionKey, ukycCapabilityToken, }, @@ -1256,6 +1514,22 @@ export class KycController extends BaseController< } return result; } catch (error) { + // Applicant already finished KYC — treat as completed for Money toast. + if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) { + this.#applyUserStatus({ + status: 'completed', + sumsubSessionId: null, + errorCode: null, + }); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + state.sumsub.result = { alreadyCompleted: true }; + state.statusMessage = 'KYC already completed.'; + state.phase = 'done'; + state.error = null; + }); + return { alreadyCompleted: true }; + } const result = { error: String(error) }; this.#updateIfCurrent(generation, (state) => { state.sumsub.status = 'failed'; @@ -1265,6 +1539,134 @@ export class KycController extends BaseController< } } + /** + * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`, + * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and + * schedules short-interval polling while the status is `pending`. + * + * @returns The latest status payload. + */ + async refreshKycStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const payload = await this.#fetchAndApplyUserStatus(); + if (payload.status === 'pending') { + this.#ensureUserStatusPolling(); + } else { + this.#stopUserStatusPolling(); + } + return payload; + } + + /** + * Fetches `GET /kyc/status` and applies it to state without managing the + * poll loop (used by both {@link refreshKycStatus} and the poll tick). + * + * @returns The latest status payload. + */ + async #fetchAndApplyUserStatus(): Promise<{ + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }> { + const generation = this.#generation; + const response = await this.messenger.call('KycService:fetchKycStatus'); + if (this.#generation !== generation) { + return { + status: this.state.userStatus ?? 'not-started', + sumsubSessionId: this.state.userStatusSumsubSessionId, + errorCode: this.state.userStatusErrorCode, + }; + } + const payload = { + status: response.status, + sumsubSessionId: response.sumsubSessionId ?? null, + errorCode: response.errorCode ?? null, + }; + this.#applyUserStatus(payload); + return payload; + } + + /** + * Writes user-keyed status onto state and publishes `statusChanged` when the + * value actually changes. + * + * @param payload - The status payload to apply. + */ + #applyUserStatus(payload: { + status: KycUserStatus; + sumsubSessionId: string | null; + errorCode: string | null; + }): void { + const previous = this.state.userStatus; + this.#applyUpdate((state) => { + state.userStatus = payload.status; + state.userStatusSumsubSessionId = payload.sumsubSessionId; + state.userStatusErrorCode = payload.errorCode; + }); + if (previous !== payload.status) { + this.messenger.publish(`${controllerName}:statusChanged`, payload); + } + } + + /** + * Starts the user-status poll loop when not already running and status is + * still `pending`. + */ + #ensureUserStatusPolling(): void { + if (this.#userStatusPollTimer !== null) { + return; + } + const token = this.#userStatusPollToken; + const tick = async (): Promise => { + try { + const payload = await this.#fetchAndApplyUserStatus(); + // Race with `reset()` / `#stopUserStatusPolling` while the request was + // in flight — do not reschedule onto an idle controller. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + if (payload.status !== 'pending') { + this.#stopUserStatusPolling(); + return; + } + } catch { + // Keep polling on transient errors, unless the loop was superseded. + /* istanbul ignore next */ + if (this.#userStatusPollToken !== token) { + return; + } + } + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + // Allow the process to exit while a pending-status poll is scheduled. + this.#userStatusPollTimer.unref(); + }; + this.#userStatusPollTimer = setTimeout(() => { + this.#userStatusPollTimer = null; + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#userStatusPollIntervalMs); + this.#userStatusPollTimer.unref(); + } + + /** + * Stops the user-keyed status poll loop. + */ + #stopUserStatusPolling(): void { + this.#userStatusPollToken += 1; + if (this.#userStatusPollTimer !== null) { + clearTimeout(this.#userStatusPollTimer); + this.#userStatusPollTimer = null; + } + } + /** * Fetches the current UKYC session status for the active sub-flow and records * it on state. Useful for a one-off refresh outside the automatic polling @@ -1397,6 +1799,7 @@ export class KycController extends BaseController< // Stop any session-status polling so a late poll cannot write onto the // now-idle controller. this.#stopPolling(); + this.#stopUserStatusPolling(); // Invalidate any in-flight async work started before this reset so its // results are discarded rather than written onto the now-idle controller. this.#generation += 1; @@ -1409,6 +1812,7 @@ export class KycController extends BaseController< state.sessionToken = null; state.accessToken = null; state.moonpayCustomerId = null; + state.activeVendor = 'moonpay'; state.activeProduct = null; state.sumsub = { status: 'idle', diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 2d642d9ab27..7f38f86aa14 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -53,6 +53,66 @@ export type KycServiceCheckKycRequiredAction = { handler: KycService['checkKycRequired']; }; +/** + * Creates (or resumes) an Iron empty-shell customer for the authenticated + * canonical user. Must run before showing Iron T&C so the customer exists in + * `SigningsRequired` and resume logic can key off Iron status. + * + * @param params - The parameters. + * @param params.email - Email associated with the Iron customer. + * @returns The Iron customer record (subset validated for controller use). + */ +export type KycServiceCreateIronCustomerAction = { + type: `KycService:createIronCustomer`; + handler: KycService['createIronCustomer']; +}; + +/** + * Fetches Iron disclaimers / terms the customer must accept before consents + * and the SumSub sub-flow. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchIronDisclaimersAction = { + type: `KycService:fetchIronDisclaimers`; + handler: KycService['fetchIronDisclaimers']; +}; + +/** + * Checks whether Iron still requires KYC for the authenticated canonical + * user. Unlike the MoonPay variant, this does not take an access token. + * + * @returns Whether KYC is required. + */ +export type KycServiceCheckIronKycRequiredAction = { + type: `KycService:checkIronKycRequired`; + handler: KycService['checkIronKycRequired']; +}; + +/** + * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * authenticated user. The API responds with 204 No Content on success. + * + * @param params - The consent parameters. + */ +export type KycServiceSubmitConsentsAction = { + type: `KycService:submitConsents`; + handler: KycService['submitConsents']; +}; + +/** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ +export type KycServiceFetchKycStatusAction = { + type: `KycService:fetchKycStatus`; + handler: KycService['fetchKycStatus']; +}; + /** * Requests a per-session wrapping key from the UKYC backend. * @@ -133,6 +193,11 @@ export type KycServiceMethodActions = | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction + | KycServiceCreateIronCustomerAction + | KycServiceFetchIronDisclaimersAction + | KycServiceCheckIronKycRequiredAction + | KycServiceSubmitConsentsAction + | KycServiceFetchKycStatusAction | KycServiceGetWrappingKeyAction | KycServiceFetchJwksAction | KycServiceCreateUkycSessionAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 2b3e57e86a0..80caa8e8cdb 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -424,6 +424,276 @@ describe('KycService', () => { service.getSessionStatus({ sessionId: 'sid' }), ).rejects.toThrow(/failed with status '404'/u); }); + + it('includes the API error message in HttpError when present', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('includes the API error field in HttpError when message is absent', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('prefers a string error field when message is not a string', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 123, error: 'session_not_in_valid_state' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/session_not_in_valid_state/u); + }); + + it('falls back to status-only HttpError when the body has no useful fields', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(409, { message: 1, error: 2 }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + + it('falls back to status-only HttpError when the body is not an object', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(409, null); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '409'$/u); + }); + }); + + describe('createIronCustomer', () => { + it('creates an Iron customer and returns the validated subset', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/customers', { email: 'a@b.co' }) + .reply(200, { + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + customer_type: 'Person', + name: '', + partner_id: 'p', + identification_ids: [], + signing_ids: [], + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', + }); + const { service } = getService(); + + await expect( + service.createIronCustomer({ email: 'a@b.co' }), + ).resolves.toMatchObject({ + id: 'iron-1', + email: 'a@b.co', + status: 'SigningsRequired', + }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/customers').reply(200, {}); + const { service } = getService(); + + await expect( + service.createIronCustomer({ email: 'a@b.co' }), + ).rejects.toThrow(/Malformed response received from iron customers API/u); + }); + }); + + describe('fetchIronDisclaimers', () => { + it('returns Iron disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Iron Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect( + await service.fetchIronDisclaimers({ country: 'USA' }), + ).toStrictEqual(disclaimers); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/iron/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchIronDisclaimers({ country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from iron disclaimers API/u, + ); + }); + }); + + describe('checkIronKycRequired', () => { + it('returns whether Iron KYC is required', async () => { + nock(MOCK_API_URL) + .post('/vendors/iron/kyc-required') + .reply(200, { required: true }); + const { service } = getService(); + + expect(await service.checkIronKycRequired()).toStrictEqual({ + kycRequired: true, + }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/iron/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect(service.checkIronKycRequired()).rejects.toThrow( + /Malformed response received from iron kyc-required API/u, + ); + }); + }); + + describe('submitConsents', () => { + it('posts consents and accepts a 204 response', async () => { + nock(MOCK_API_URL) + .post('/consents', { + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + kycLevel: 'standard', + }) + .reply(204); + const { service } = getService(); + + await expect( + service.submitConsents({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }), + ).resolves.toBeUndefined(); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).post('/consents').reply(500); + const { service } = getService(); + + await expect( + service.submitConsents({ + ironDisclaimerIds: ['d1'], + sumsubTncSigned: true, + idosTncSigned: true, + }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('fetchKycStatus', () => { + it('returns the simplified user-keyed status', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { + status: 'pending', + sumsubSessionId: 'ss-1', + }); + const { service } = getService(); + + expect(await service.fetchKycStatus()).toStrictEqual({ + status: 'pending', + sumsubSessionId: 'ss-1', + }); + }); + + it('throws on an unknown status value', async () => { + nock(MOCK_API_URL).get('/kyc/status').reply(200, { status: 'weird' }); + const { service } = getService(); + + await expect(service.fetchKycStatus()).rejects.toThrow( + /Malformed response received from kyc status API/u, + ); + }); + }); + + describe('createUkycSession vendorId', () => { + it('defaults vendorId to moonpay and forwards vendorMetadata', async () => { + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }); + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'moonpay' && + body.vendorMetadata?.moonPayAccessToken === 'tok' + ); + }) + .reply(200, { sessionId: 'sid' }); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { moonPayAccessToken: 'tok' }, + wrappedEncryptionKey: { + sessionId: 'wk', + encryptedKey: 'ek', + nonce: 'n', + }, + ukycCapabilityToken, + }), + ).toStrictEqual({ sessionId: 'sid' }); + }); + + it('sends vendorId iron with empty vendorMetadata when omitted', async () => { + const material = deriveClientMaterial( + new Uint8Array(UKYC_LOCAL_USER_SECRET_SIZE_BYTES).fill(1), + ); + const ukycCapabilityToken = signStorageAccessToken({ + material, + operations: ['read'], + expiresAt: new Date('2099-01-01T00:00:00.000Z'), + }); + nock(MOCK_API_URL) + .post('/sessions', (body) => { + return ( + body.vendorId === 'iron' && + JSON.stringify(body.vendorMetadata) === '{}' + ); + }) + .reply(200, { sessionId: 'sid-iron' }); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorId: 'iron', + wrappedEncryptionKey: { + sessionId: 'wk', + encryptedKey: 'ek', + nonce: 'n', + }, + ukycCapabilityToken, + }), + ).toStrictEqual({ sessionId: 'sid-iron' }); + }); }); describe('baseUrl', () => { diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 460105d4d4a..9ab5f59247b 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -14,6 +14,7 @@ import { array, assert, boolean, + enums, optional, string, StructError, @@ -25,7 +26,12 @@ import type { QueryClientConfig } from '@tanstack/query-core'; import { alpha2ToAlpha3 } from './countryCodes.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; -import type { KycDisclaimer, KycSessionStatus } from './types.js'; +import type { + KycDisclaimer, + KycSessionStatus, + KycUserStatusResponse, + KycVendor, +} from './types.js'; import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; @@ -44,6 +50,11 @@ const MESSENGER_EXPOSED_METHODS = [ 'fetchDisclaimers', 'createSession', 'checkKycRequired', + 'createIronCustomer', + 'fetchIronDisclaimers', + 'checkIronKycRequired', + 'submitConsents', + 'fetchKycStatus', 'getWrappingKey', 'fetchJwks', 'createUkycSession', @@ -199,6 +210,29 @@ const SessionStatusResponseStruct = type({ vendorStatus: string(), }); +// Iron customer subset — `type` (not `object`) keeps extra Iron fields from +// failing validation while still requiring the fields the controller needs. +const IronCustomerResponseStruct = type({ + id: string(), + email: string(), + status: string(), +}); +export type IronCustomerResponse = Infer; + +const KYC_USER_STATUSES = [ + 'not-started', + 'pending', + 'need-more-information', + 'terminal-failure', + 'completed', +] as const; + +const KycUserStatusResponseStruct = type({ + status: enums([...KYC_USER_STATUSES]), + sumsubSessionId: optional(string()), + errorCode: optional(string()), +}); + // === PARAM TYPES === export type CreateSessionParams = { @@ -213,6 +247,17 @@ export type CheckKycRequiredParams = { capabilities?: { product: string }[]; }; +export type CreateIronCustomerParams = { + email: string; +}; + +export type SubmitConsentsParams = { + ironDisclaimerIds: string[]; + sumsubTncSigned: boolean; + idosTncSigned: boolean; + kycLevel?: 'standard'; +}; + export type GetWrappingKeyParams = { sessionClientPublicKey: string; }; @@ -230,7 +275,17 @@ export type WrappedEncryptionKey = { export type CreateUkycSessionParams = { jwtToken: string; - vendorMetadata: Record; + /** + * Identity vendor for the UKYC session. Defaults to `moonpay` for the + * existing Check/Auth flow. Pass `iron` for the Money/VBA path (no MoonPay + * metadata required). + */ + vendorId?: KycVendor; + /** + * Vendor-specific metadata. Required for MoonPay (`moonPayAccessToken` / + * `moonPayUserId`); optional / omitted for Iron. + */ + vendorMetadata?: Record; wrappedEncryptionKey: WrappedEncryptionKey; /** * The client-signed `ukyc_capability_token` (envelope: payload + Ed25519 @@ -445,6 +500,140 @@ export class KycService extends BaseDataService< return { kycRequired: required }; } + /** + * Creates (or resumes) an Iron empty-shell customer for the authenticated + * canonical user. Must run before showing Iron T&C so the customer exists in + * `SigningsRequired` and resume logic can key off Iron status. + * + * @param params - The parameters. + * @param params.email - Email associated with the Iron customer. + * @returns The Iron customer record (subset validated for controller use). + */ + async createIronCustomer( + params: CreateIronCustomerParams, + ): Promise { + const url = new URL('/vendors/iron/customers', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:createIronCustomer`, params.email], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ email: params.email }), + }), + // Customer creation/resume must never serve a stale/cached result. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + IronCustomerResponseStruct, + 'iron customers', + ); + } + + /** + * Fetches Iron disclaimers / terms the customer must accept before consents + * and the SumSub sub-flow. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchIronDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/iron/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchIronDisclaimers`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: inMilliseconds(5, Duration.Minute), + }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'iron disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Checks whether Iron still requires KYC for the authenticated canonical + * user. Unlike the MoonPay variant, this does not take an access token. + * + * @returns Whether KYC is required. + */ + async checkIronKycRequired(): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/iron/kyc-required', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:checkIronKycRequired`], + queryFn: async () => this.#requestJson(url, { method: 'POST', body: '{}' }), + // The requirement can change server-side, so always re-check. + staleTime: 0, + cacheTime: 0, + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'iron kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Posts T&C1 (Iron signings) and T&C2 (Sumsub + idOS) consents for the + * authenticated user. The API responds with 204 No Content on success. + * + * @param params - The consent parameters. + */ + async submitConsents(params: SubmitConsentsParams): Promise { + const url = new URL('/consents', this.#baseUrl); + await this.fetchQuery({ + queryKey: [ + `${this.name}:submitConsents`, + params.ironDisclaimerIds, + params.sumsubTncSigned, + params.idosTncSigned, + params.kycLevel ?? 'standard', + ], + queryFn: async () => + this.#requestJson(url, { + method: 'POST', + body: JSON.stringify({ + ironDisclaimerIds: params.ironDisclaimerIds, + sumsubTncSigned: params.sumsubTncSigned, + idosTncSigned: params.idosTncSigned, + kycLevel: params.kycLevel ?? 'standard', + }), + }), + staleTime: 0, + cacheTime: 0, + }); + } + + /** + * Fetches the user-keyed simplified KYC status used by Money toast / banner + * surfaces (`GET /kyc/status`). + * + * @returns The simplified status payload. + */ + async fetchKycStatus(): Promise { + const url = new URL('/kyc/status', this.#baseUrl); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchKycStatus`], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + // Status is polled for toast flips, so it must always be fresh. + staleTime: 0, + cacheTime: 0, + }); + return this.#validateResponse( + data, + KycUserStatusResponseStruct, + 'kyc status', + ); + } + /** * Requests a per-session wrapping key from the UKYC backend. * @@ -530,10 +719,10 @@ export class KycService extends BaseDataService< this.#requestJson(url, { method: 'POST', body: JSON.stringify({ - vendorId: 'moonpay', + vendorId: params.vendorId ?? 'moonpay', vendorUserId: 'mockedId', jwtToken: params.jwtToken, - vendorMetadata: params.vendorMetadata, + vendorMetadata: params.vendorMetadata ?? {}, wrappedEncryptionKey: params.wrappedEncryptionKey, ukycCapabilityToken: encodeStorageAccessTokenForHeader( params.ukycCapabilityToken, @@ -693,12 +882,33 @@ export class KycService extends BaseDataService< headers, }); if (!response.ok) { + let detail = ''; + try { + const errorBody: unknown = await response.json(); + if (errorBody && typeof errorBody === 'object') { + const record = errorBody as Record; + if (typeof record.message === 'string') { + detail = record.message; + } else if (typeof record.error === 'string') { + detail = record.error; + } + } + } catch { + // Ignore body parse failures; status alone is still useful. + } throw new HttpError( response.status, - `Fetching '${url.toString()}' failed with status '${response.status}'`, + `Fetching '${url.toString()}' failed with status '${response.status}'${ + detail ? `: ${detail}` : '' + }`, ); } + // Consent (and similar) endpoints return 204 No Content. + if (response.status === 204) { + return null; + } + return (await response.json()) as Json; } } diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 20830da2c55..a3f79c15aad 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -11,6 +11,7 @@ export type { KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, + KycControllerStatusChangedEvent, } from './KycController.js'; export type { KycControllerAcceptTermsAndStartSessionAction, @@ -19,11 +20,13 @@ export type { KycControllerBuildResetFrameUrlAction, KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, + KycControllerCreateIronCustomerAction, KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, KycControllerInitializeAction, KycControllerLoadDisclaimersAction, + KycControllerRefreshKycStatusAction, KycControllerResetAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -32,10 +35,12 @@ export { KycService, serviceName } from './KycService.js'; export type { ApplicantAccessTokenResponse, CheckKycRequiredParams, + CreateIronCustomerParams, CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, GetWrappingKeyParams, + IronCustomerResponse, JwksResponse, KycServiceActions, KycServiceCacheUpdatedEvent, @@ -44,20 +49,26 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + SubmitConsentsParams, UkycSessionResponse, WrappedEncryptionKey, WrappingKeyResponse, } from './KycService.js'; export type { + KycServiceCheckIronKycRequiredAction, KycServiceCheckKycRequiredAction, + KycServiceCreateIronCustomerAction, KycServiceCreateJourneyAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, KycServiceFetchDisclaimersAction, + KycServiceFetchIronDisclaimersAction, KycServiceFetchJwksAction, + KycServiceFetchKycStatusAction, KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, + KycServiceSubmitConsentsAction, } from './KycService-method-action-types.js'; export { @@ -83,6 +94,8 @@ export type { KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubStatus, + KycUserStatus, + KycUserStatusResponse, KycVendor, } from './types.js'; diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 1323aea0c1b..62b75370c49 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -8,29 +8,59 @@ /** * A MetaMask feature that consumes KYC. Used to key the per-product - * "is KYC required" cache so ramps and card can share one controller. + * "is KYC required" cache so ramps, card, and money can share one controller. */ -export type KycProduct = 'ramps' | 'card'; +export type KycProduct = 'ramps' | 'card' | 'money'; /** * Identity vendors supported behind the KYC surface. + * + * - `moonpay` — MoonPay Check/Auth frames + SumSub documents. + * - `iron` — Iron-only Money/VBA path: empty-shell customer → consents → + * SumSub, with no MoonPay Check/Auth frames. + */ +export type KycVendor = 'moonpay' | 'iron'; + +/** + * User-keyed KYC status returned by `GET /kyc/status` and stored for Money + * toast / banner rendering. Collapses Iron + SumSub / relay state into the + * offsite contract. */ -export type KycVendor = 'moonpay'; +export type KycUserStatus = + | 'not-started' + | 'pending' + | 'need-more-information' + | 'terminal-failure' + | 'completed'; + +/** + * Payload from `GET /kyc/status`, including optional fields that power the + * 3-state error contract (retryable SumSub vs terminal vs EDD). + */ +export type KycUserStatusResponse = { + status: KycUserStatus; + /** Present when the user can reopen a SumSub session (retryable path). */ + sumsubSessionId?: string; + /** Machine-readable error code for terminal / EDD UX. */ + errorCode?: string; +}; /** * Phases of the end-to-end identity flow. * * - `idle` — nothing started. * - `terms` — waiting for the customer to accept the vendor terms. - * - `session` — creating the vendor session. - * - `check` — running the invisible connection-check frame. - * - `auth` — running the visible authentication (OTP) frame. + * - `session` — creating the vendor session (MoonPay) or posting consents + * (Iron). + * - `check` — running the invisible connection-check frame (MoonPay only). + * - `auth` — running the visible authentication (OTP) frame (MoonPay only). * - `form` — authenticated. When the flow is scoped to a product, the * KYC-required check runs automatically from here; otherwise the consumer - * drives it manually via `checkKycRequired`. - * - `submit` — submitting the KYC-required check. - * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is - * required, the document-verification sub-flow is launched automatically. + * drives it manually via `checkKycRequired`. Iron skips this phase. + * - `submit` — submitting the KYC-required check / launching SumSub. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub` / + * `userStatus`. When KYC is required, the document-verification sub-flow is + * launched automatically. * - `error` — flow halted; see `error`. */ export type KycPhase = From 13585d808ef624c18bb66419179acd61f51dc950 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 12 Aug 2026 16:02:47 -0600 Subject: [PATCH 57/67] feat(ramps): resolve autoramp customer id from KYC controller Autoramp creation needs the MoonPay customer id, which is owned by KycController and captured from the Check/Auth frames. Rather than have the UI read and plumb it, expose a narrow KycController:getCustomerIdentity action returning the vendor-scoped { vendor, id } (or null before auth and after reset), so consumers do not need access to the full KYC state, which also holds session and access tokens. RampsController.createAutoramp resolves that identity over the messenger, injects customer_id into the request, forwards the body to NeoBankService:createAutoramp, and applies the returned snapshot. The id is session-scoped and is never persisted by RampsController. Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 3 +- .../src/KycController-method-action-types.ts | 18 ++++++ .../kyc-controller/src/KycController.test.ts | 38 +++++++++++++ packages/kyc-controller/src/KycController.ts | 22 ++++++++ packages/kyc-controller/src/index.ts | 2 + packages/kyc-controller/src/types.ts | 16 ++++++ packages/ramps-controller/CHANGELOG.md | 3 + packages/ramps-controller/package.json | 1 + .../RampsController-method-action-types.ts | 21 +++++++ .../src/RampsController.test.ts | 54 ++++++++++++++++++ .../ramps-controller/src/RampsController.ts | 56 ++++++++++++++++++- .../ramps-controller/src/autorampAccount.ts | 10 ++++ packages/ramps-controller/src/index.ts | 3 + yarn.lock | 3 +- 14 files changed, 247 insertions(+), 3 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 51617299f34..b0cc2fbe4c3 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,9 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. - Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) -- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index a6b747d88ca..b2aa85cca42 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -145,6 +145,23 @@ export type KycControllerGetKycStatusAction = { handler: KycController['getKycStatus']; }; +/** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}). + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ +export type KycControllerGetCustomerIdentityAction = { + type: `KycController:getCustomerIdentity`; + handler: KycController['getCustomerIdentity']; +}; + /** * Runs the SumSub document-verification sub-flow end to end: * @@ -222,6 +239,7 @@ export type KycControllerMethodActions = | KycControllerBuildResetFrameUrlAction | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction + | KycControllerGetCustomerIdentityAction | KycControllerStartSumSubAction | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index c74b32b7f6a..fdebac325f8 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1215,6 +1215,44 @@ describe('KycController', () => { }); }); + describe('getCustomerIdentity', () => { + it('returns null before a vendor customer id is captured', async () => { + await withController(({ controller }) => { + expect(controller.getCustomerIdentity()).toBeNull(); + }); + }); + + it('returns the vendor-scoped identity once captured', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + expect(controller.getCustomerIdentity()).toStrictEqual({ + vendor: 'moonpay', + id: 'cust-1', + }); + }, + ); + }); + + it('returns null after reset clears the captured id', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + }); + describe('startSumSub', () => { it('throws and marks failed when the SDK is unavailable', async () => { await withController(async ({ controller, launcher }) => { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 242072501ad..bc2c1582f91 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -17,6 +17,7 @@ import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { + KycCustomerIdentity, KycDisclaimer, KycPhase, KycProduct, @@ -375,6 +376,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'buildResetFrameUrl', 'checkKycRequired', 'getKycStatus', + 'getCustomerIdentity', 'refreshKycStatus', 'startSumSub', 'getSessionStatus', @@ -1289,6 +1291,26 @@ export class KycController extends BaseController< return this.state.kycRequiredByProduct[params.product]; } + /** + * Returns the vendor-scoped identity for the currently authenticated + * customer, or `null` when the flow has not yet captured a vendor customer + * id (before authentication or after {@link reset}). + * + * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor + * customer id to downstream calls without reading the full KYC state, which + * also holds session/access tokens. The id is session-scoped and never + * persisted. + * + * @returns The current {@link KycCustomerIdentity}, or `null`. + */ + getCustomerIdentity(): KycCustomerIdentity | null { + const { moonpayCustomerId, activeVendor } = this.state; + if (!moonpayCustomerId) { + return null; + } + return { vendor: activeVendor, id: moonpayCustomerId }; + } + /** * Runs the SumSub document-verification sub-flow end to end: * diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index a3f79c15aad..d6b24b3730a 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -21,6 +21,7 @@ export type { KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, KycControllerCreateIronCustomerAction, + KycControllerGetCustomerIdentityAction, KycControllerGetKycStatusAction, KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, @@ -87,6 +88,7 @@ export type { } from './crypto.js'; export type { + KycCustomerIdentity, KycDisclaimer, KycPhase, KycProduct, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 62b75370c49..9b1ee6ebfa8 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -21,6 +21,22 @@ export type KycProduct = 'ramps' | 'card' | 'money'; */ export type KycVendor = 'moonpay' | 'iron'; +/** + * Vendor-scoped identity for the currently authenticated KYC customer. + * + * Exposed to consumers (e.g. ramps) that must attach the vendor customer id to + * downstream provider calls without reading the full KYC state, which also + * holds session/access tokens. The identifier is session-scoped: it is only + * available once the customer has authenticated through the current flow and + * is cleared on `reset()`. + */ +export type KycCustomerIdentity = { + /** The identity vendor that issued {@link KycCustomerIdentity.id}. */ + vendor: KycVendor; + /** The vendor customer id (e.g. MoonPay customer UUID). */ + id: string; +}; + /** * User-keyed KYC status returned by `GET /kyc/status` and stored for Money * toast / banner rendering. Collapses Iron + SumSub / relay state into the diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 557119c6903..e14984e756a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,11 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from the KYC controller via the new `KycController:getCustomerIdentity` action, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when no verified KYC identity is available. +- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`KycController:getCustomerIdentity`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation. - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) ### Changed +- Add a dependency on `@metamask/kyc-controller` so `RampsController` can resolve the vendor customer identity when creating autoramps. - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ## [20.0.0] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 36b5fafa5e8..296d1c41405 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -58,6 +58,7 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", + "@metamask/kyc-controller": "^0.0.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0" diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 4fa4d1f19e7..75c4077fa58 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -293,6 +293,26 @@ export type RampsControllerAddAutorampAction = { handler: RampsController['addAutoramp']; }; +/** + * Creates an autoramp via the Ramp API neo-bank proxy and applies the + * returned snapshot locally. + * + * The MoonPay `customer_id` is not accepted from callers: it is resolved from + * the KYC controller's session-scoped identity and injected into the request. + * This keeps the sensitive customer id owned by the KYC controller and avoids + * requiring the UI to know or plumb it. Throws when no verified identity is + * available yet. + * + * @param request - CreateAutoramp payload (any `customer_id` is overwritten). + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ +export type RampsControllerCreateAutorampAction = { + type: `RampsController:createAutoramp`; + handler: RampsController['createAutoramp']; +}; + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. @@ -769,6 +789,7 @@ export type RampsControllerMethodActions = | RampsControllerAddOrderAction | RampsControllerRemoveOrderAction | RampsControllerAddAutorampAction + | RampsControllerCreateAutorampAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction | RampsControllerApplyAutorampStatusFromPushAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e71ecf6b9df..c75195cf098 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -23,6 +23,7 @@ import { RampsController, getDefaultRampsControllerState, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; import { RAMPS_ERROR_CODES } from './rampsErrorCodes.js'; @@ -9031,6 +9032,58 @@ describe('RampsController', () => { }); }); + it('injects the KYC customer id and applies the created autoramp', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + () => ({ vendor: 'moonpay', id: 'cust-99' }), + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-99', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + const created = await controller.createAutoramp( + { customer_id: 'attacker-supplied', foo: 'bar' }, + { idempotencyKey: 'idem-1' }, + ); + + expect(createAutoramp).toHaveBeenCalledWith( + { foo: 'bar', customer_id: 'cust-99' }, + { idempotencyKey: 'idem-1' }, + ); + expect(created.id).toBe('ar-new'); + expect( + controller.state.autoramps.find((a) => a.id === 'ar-new')?.customerId, + ).toBe('cust-99'); + }); + }); + + it('throws when no KYC customer identity is available', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + () => null, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /no verified KYC customer identity/u, + ); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + it('skips failed refreshes when refreshing all autoramps', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( @@ -12032,6 +12085,7 @@ function getMessenger(rootMessenger: RootMessenger): RampsControllerMessenger { messenger, actions: [ ...RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + ...RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, ...RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, 'RemoteFeatureFlagController:getState', ], diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e3d41c7185a..6d580857acd 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,9 +15,12 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; +import type { KycControllerGetCustomerIdentityAction } from '@metamask/kyc-controller'; + import type { AutorampAccount, AutorampRemoteSnapshot, + CreateAutorampRequest, } from './autorampAccount.js'; import { applyAutorampRemoteStatus, @@ -30,7 +33,10 @@ import { updateAutorampInRemoteStorage, } from './autoramp-syncing/index.js'; import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; -import type { NeoBankServiceGetAutorampAction } from './NeoBankService-method-action-types.js'; +import type { + NeoBankServiceCreateAutorampAction, + NeoBankServiceGetAutorampAction, +} from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { UserStorageController } from '@metamask/profile-sync-controller'; @@ -191,8 +197,18 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:cancelAllActiveOrders', 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', + 'NeoBankService:createAutoramp', ]; +/** + * Other controller actions RampsController calls via the messenger. + * Hosts that enable autoramp creation must delegate these from the root + * messenger so the controller can resolve the vendor customer identity. + */ +export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ + 'KycController:getCustomerIdentity', +] as const; + /** * User Storage / auth actions needed for autoramp Backup & Sync. * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. @@ -685,6 +701,8 @@ type AllowedActions = | TransakServiceCancelAllActiveOrdersAction | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction + | NeoBankServiceCreateAutorampAction + | KycControllerGetCustomerIdentityAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -878,6 +896,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'addOrder', 'removeOrder', 'addAutoramp', + 'createAutoramp', 'removeAutoramp', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', @@ -2630,6 +2649,41 @@ export class RampsController extends BaseController< return upserted; } + /** + * Creates an autoramp via the Ramp API neo-bank proxy and applies the + * returned snapshot locally. + * + * The MoonPay `customer_id` is not accepted from callers: it is resolved from + * the KYC controller's session-scoped identity and injected into the request. + * This keeps the sensitive customer id owned by the KYC controller and avoids + * requiring the UI to know or plumb it. Throws when no verified identity is + * available yet. + * + * @param request - CreateAutoramp payload (any `customer_id` is overwritten). + * @param options - Optional idempotency key forwarded to the proxy. + * @param options.idempotencyKey - Value sent as `Idempotency-Key`. + * @returns The created/updated local {@link AutorampAccount}. + */ + async createAutoramp( + request: CreateAutorampRequest, + options: { idempotencyKey?: string } = {}, + ): Promise { + const identity = this.messenger.call('KycController:getCustomerIdentity'); + if (!identity) { + throw new Error( + 'Cannot create autoramp: no verified KYC customer identity is available.', + ); + } + + const body = { ...request, customer_id: identity.id }; + const remote = await this.messenger.call( + 'NeoBankService:createAutoramp', + body, + options, + ); + return this.#applyAutorampRemoteSnapshot(remote); + } + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts index 0cdec9615a6..b812499a401 100644 --- a/packages/ramps-controller/src/autorampAccount.ts +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -56,6 +56,16 @@ export type AutorampAccount = { depositRailsSummary?: AutorampDepositRailsSummary; }; +/** + * Controller-facing payload for creating an autoramp. + * + * Mirrors the MoonPay `POST /api/autoramps` body that + * {@link NeoBankService.createAutoramp} forwards opaquely, minus `customer_id`: + * `RampsController.createAutoramp` injects the vendor customer id resolved from + * the KYC controller, so callers never supply (or need to know) it. + */ +export type CreateAutorampRequest = Record; + /** * Minimal remote snapshot from `GET /api/autoramps/{id}` (or a push payload). * Host apps / BFF map MoonPay responses into this shape. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 5dbe85e45ed..d8f3f827c52 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -31,6 +31,7 @@ export type { RampsControllerAddOrderAction, RampsControllerRemoveOrderAction, RampsControllerAddAutorampAction, + RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, @@ -75,6 +76,7 @@ export { getDefaultRampsControllerState, getInternalOrderCode, RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS, + RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS, RAMPS_CONTROLLER_AUTORAMP_SYNC_ACTIONS, } from './RampsController.js'; export type { @@ -179,6 +181,7 @@ export type { AutorampDepositRailsSummary, AutorampRemoteSnapshot, ApplyAutorampRemoteStatusResult, + CreateAutorampRequest, } from './autorampAccount.js'; export { AutorampStatus, diff --git a/yarn.lock b/yarn.lock index 467ee181753..944b4e7b125 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7679,7 +7679,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@npm:^0.0.0, @metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -8687,6 +8687,7 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/kyc-controller": "npm:^0.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" From 7e209f77c425d8f874e2298b099a8edb145e2c7f Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Wed, 12 Aug 2026 16:23:13 -0600 Subject: [PATCH 58/67] fix(kyc-controller): sync Iron CI lint fixes onto neobank-demo (#9855) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `neobank-demo` already includes Sébastien’s `feat/kyc-controller` stack and the Iron feature commit from #9852 (`a40fa2fc3f`, merged by George). - This PR cherry-picks the remaining #9852 CI fixes (`lint` / changelog PR link / `oxfmt`) so the demo branch matches the Iron tip for package publish/preview and mobile consumption. - Keeps #9852 targeting `feat/kyc-controller` for the eventual mainline path via #9615. Does **not** merge to `main`. ## Context (demo topology) - Integration branch: `neobank-demo` (owner: @georgeweiler) — umbrella PR #9853 → `main` - Iron feature PR (mainline stack): #9852 → `feat/kyc-controller` - KYC controller PR: #9615 → `main` ## Test plan - [ ] Confirm diff is formatting/lint/changelog only (no Iron behavior change) - [ ] CI green on this PR - [ ] After merge, `@metamaskbot publish-preview` on #9853 (or this PR) if mobile needs a preview pin of `@metamask/kyc-controller` Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor --- packages/kyc-controller/ARCHITECTURE.md | 2 +- packages/kyc-controller/CHANGELOG.md | 2 +- packages/kyc-controller/README.md | 2 +- .../scripts/mint-ukyc-test-token.ts | 4 +++- .../kyc-controller/src/KycController.test.ts | 8 ++++++-- packages/kyc-controller/src/KycController.ts | 16 +++++++++------- packages/kyc-controller/src/KycService.test.ts | 12 ++++++------ packages/kyc-controller/src/KycService.ts | 8 +++++--- .../src/ukyc/deriveClientMaterial.ts | 2 +- .../src/ukyc/storageAccessToken.ts | 2 +- .../kyc-controller/src/ukyc/testToken.test.ts | 10 +++++----- .../src/ukyc/wrappedRelayPayload.ts | 2 +- 12 files changed, 40 insertions(+), 30 deletions(-) diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index 265a538fbcf..27ceff651a6 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -658,6 +658,6 @@ Reference client (metamask-mobile): | `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | | `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | | `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | -| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | | `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | | `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index b0cc2fbe4c3..862fdea025b 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. -- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state +- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852)) - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. diff --git a/packages/kyc-controller/README.md b/packages/kyc-controller/README.md index 32ee194a139..7aaf642375e 100644 --- a/packages/kyc-controller/README.md +++ b/packages/kyc-controller/README.md @@ -20,4 +20,4 @@ This watches `src/**/*.ts` and re-runs the build on each change (it also perform ## Contributing -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). \ No newline at end of file +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/kyc-controller/scripts/mint-ukyc-test-token.ts b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts index 82b51801acd..10b47b1ea4c 100644 --- a/packages/kyc-controller/scripts/mint-ukyc-test-token.ts +++ b/packages/kyc-controller/scripts/mint-ukyc-test-token.ts @@ -98,7 +98,9 @@ if (flags['expires-at']) { } else if (flags['expires-in']) { const issuedAt = params.issuedAt ?? new Date(); params.issuedAt = issuedAt; - params.expiresAt = new Date(issuedAt.getTime() + parseDurationMs(flags['expires-in'])); + params.expiresAt = new Date( + issuedAt.getTime() + parseDurationMs(flags['expires-in']), + ); } const result = mintUkycTestToken(params); diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index fdebac325f8..aef1d4f36e9 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1848,7 +1848,9 @@ describe('KycController', () => { await controller.initialize({ email: 'a@b.co', vendor: 'iron' }); expect(controller.state.phase).toBe('error'); - expect(controller.state.error).toMatch(/Iron customer creation failed/u); + expect(controller.state.error).toMatch( + /Iron customer creation failed/u, + ); }); }); @@ -2065,7 +2067,9 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.createUkycSession.mockRejectedValue(new Error('sumsub down')); + handlers.createUkycSession.mockRejectedValue( + new Error('sumsub down'), + ); handlers.fetchIronDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index bc2c1582f91..29e7f21e12e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -14,6 +14,7 @@ import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; +import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; import type { @@ -28,7 +29,6 @@ import type { KycVendor, } from './types.js'; import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js'; -import { toBase64Url } from './encoding.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; @@ -806,6 +806,8 @@ export class KycController extends BaseController< * launch SumSub — skipping MoonPay Check/Auth frames. * * @param consents - T&C2 boolean flags. + * @param consents.sumsubTncSigned - Whether Sumsub T&C were accepted. + * @param consents.idosTncSigned - Whether idOS T&C were accepted. */ async #startIronSession(consents: { sumsubTncSigned: boolean; @@ -848,12 +850,9 @@ export class KycController extends BaseController< if (this.#generation !== generation) { return; } - if ( - sumsubResult && - 'error' in sumsubResult && - typeof sumsubResult.error === 'string' - ) { - throw new Error(sumsubResult.error); + const sumsubError = sumsubResult?.error; + if (typeof sumsubError === 'string') { + throw new Error(sumsubError); } // After SumSub, refresh user-keyed status for the Money toast and start // polling while still pending. Soft-fail: toast refresh must not rewind @@ -1616,6 +1615,9 @@ export class KycController extends BaseController< * value actually changes. * * @param payload - The status payload to apply. + * @param payload.status - User-keyed KYC status from `GET /kyc/status`. + * @param payload.sumsubSessionId - Optional SumSub session id from status. + * @param payload.errorCode - Optional error code from status. */ #applyUserStatus(payload: { status: KycUserStatus; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 80caa8e8cdb..c7d5d6c9db9 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -497,9 +497,9 @@ describe('KycService', () => { }); const { service } = getService(); - await expect( - service.createIronCustomer({ email: 'a@b.co' }), - ).resolves.toMatchObject({ + expect( + await service.createIronCustomer({ email: 'a@b.co' }), + ).toMatchObject({ id: 'iron-1', email: 'a@b.co', status: 'SigningsRequired', @@ -581,13 +581,13 @@ describe('KycService', () => { .reply(204); const { service } = getService(); - await expect( - service.submitConsents({ + expect( + await service.submitConsents({ ironDisclaimerIds: ['d1'], sumsubTncSigned: true, idosTncSigned: true, }), - ).resolves.toBeUndefined(); + ).toBeUndefined(); }); it('throws an HttpError on a non-ok response', async () => { diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 9ab5f59247b..e155ca8f386 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -85,8 +85,9 @@ type AllowedActions = /** * Published when {@link KycService}'s cache is updated. */ -export type KycServiceCacheUpdatedEvent = - DataServiceCacheUpdatedEvent; +export type KycServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; /** * Published when a single key within {@link KycService}'s cache is updated. @@ -568,7 +569,8 @@ export class KycService extends BaseDataService< const url = new URL('/vendors/iron/kyc-required', this.#baseUrl); const data = await this.fetchQuery({ queryKey: [`${this.name}:checkIronKycRequired`], - queryFn: async () => this.#requestJson(url, { method: 'POST', body: '{}' }), + queryFn: async () => + this.#requestJson(url, { method: 'POST', body: '{}' }), // The requirement can change server-side, so always re-check. staleTime: 0, cacheTime: 0, diff --git a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts index 59ffa68d024..b9c8b8e5aa3 100644 --- a/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts +++ b/packages/kyc-controller/src/ukyc/deriveClientMaterial.ts @@ -3,8 +3,8 @@ import { ed25519 } from '@noble/curves/ed25519'; import { hkdf } from '@noble/hashes/hkdf'; import { sha256 } from '@noble/hashes/sha2'; -import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; import { toBase64Url } from '../encoding.js'; +import { UKYC_DERIVED_KEY_SIZES, UKYC_KDF_INFO } from './constants.js'; /** * Derives UKYC client material from the root `local_user_secret` using diff --git a/packages/kyc-controller/src/ukyc/storageAccessToken.ts b/packages/kyc-controller/src/ukyc/storageAccessToken.ts index abc9455a1e3..f020337696f 100644 --- a/packages/kyc-controller/src/ukyc/storageAccessToken.ts +++ b/packages/kyc-controller/src/ukyc/storageAccessToken.ts @@ -1,12 +1,12 @@ import { stringToBytes } from '@metamask/utils'; import { ed25519 } from '@noble/curves/ed25519'; +import { toBase64Url } from '../encoding.js'; import { UKYC_STORAGE_ACCESS_TOKEN_AUDIENCES, UKYC_STORAGE_ACCESS_TOKEN_VERSION, } from './constants.js'; import type { UkycClientMaterial } from './deriveClientMaterial.js'; -import { toBase64Url } from '../encoding.js'; /** * Mints `storage_access_token` capabilities — the client-signed, scoped, diff --git a/packages/kyc-controller/src/ukyc/testToken.test.ts b/packages/kyc-controller/src/ukyc/testToken.test.ts index 2784d87ba28..0098a6bbbc6 100644 --- a/packages/kyc-controller/src/ukyc/testToken.test.ts +++ b/packages/kyc-controller/src/ukyc/testToken.test.ts @@ -1,6 +1,7 @@ -import { ed25519 } from '@noble/curves/ed25519'; import { hexToBytes, stringToBytes } from '@metamask/utils'; +import { ed25519 } from '@noble/curves/ed25519'; +import { base64UrlToBytes } from '../encoding.js'; import { UKYC_CAPABILITY_AUTH_SCHEME, UKYC_STORAGE_ACCESS_TOKEN_AUDIENCE, @@ -8,7 +9,6 @@ import { } from './constants.js'; import { canonicalizeJson } from './storageAccessToken.js'; import { mintUkycTestToken } from './testToken.js'; -import { base64UrlToBytes } from '../encoding.js'; // A fixed 32-byte secret (all 0x42), as hex, so storage_id and keys are stable. const SECRET_HEX = '42'.repeat(32); @@ -101,9 +101,9 @@ describe('UKYC mintUkycTestToken', () => { // 32 bytes hex-encoded. expect(result.localUserSecret).toMatch(/^[0-9a-f]{64}$/u); expect(result.token.payload.operations).toStrictEqual(['read']); - expect(result.authorizationHeader.startsWith( - `${UKYC_CAPABILITY_AUTH_SCHEME} `, - )).toBe(true); + expect( + result.authorizationHeader.startsWith(`${UKYC_CAPABILITY_AUTH_SCHEME} `), + ).toBe(true); }); it('binds session_id for a Relay-presented token', () => { diff --git a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts index ae28ec4ec2d..2fe21e50845 100644 --- a/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts +++ b/packages/kyc-controller/src/ukyc/wrappedRelayPayload.ts @@ -1,5 +1,5 @@ -import type { UkycClientMaterial } from './deriveClientMaterial.js'; import { toBase64Url } from '../encoding.js'; +import type { UkycClientMaterial } from './deriveClientMaterial.js'; import type { UkycStorageAccessToken } from './storageAccessToken.js'; /** From 594bb218413ee57446064b086ed6df60348af9eb Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 12 Aug 2026 16:35:38 -0600 Subject: [PATCH 59/67] fix(ramps): narrow required service action list to its literal members The constant was annotated with the full RampsService/TransakService/ NeoBankService action unions, so spreading it into a messenger delegation widened the element type to every service action. Hosts that only delegate the actions RampsController actually calls (mobile) failed to typecheck once NeoBankService grew Pix and quote methods. Use `as const satisfies` so the type is the literal members while still being checked against the service action unions. Also record the kyc-controller dependency in the tsconfig project references so the package builds. Co-authored-by: Cursor --- packages/ramps-controller/src/RampsController.ts | 12 ++++++------ packages/ramps-controller/tsconfig.build.json | 3 +++ packages/ramps-controller/tsconfig.json | 3 +++ 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 6d580857acd..ba15cd39d02 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -156,11 +156,7 @@ export const controllerName = 'RampsController'; * Any host (e.g. mobile) that creates a RampsController messenger must delegate * these actions from the root messenger so the controller can function. */ -export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( - | RampsServiceActions['type'] - | TransakServiceActions['type'] - | NeoBankServiceActions['type'] -)[] = [ +export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'RampsService:getDefaultRedirectCallbackUrl', 'RampsService:getGeolocation', 'RampsService:getCountries', @@ -198,7 +194,11 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS: readonly ( 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', 'NeoBankService:createAutoramp', -]; +] as const satisfies readonly ( + | RampsServiceActions['type'] + | TransakServiceActions['type'] + | NeoBankServiceActions['type'] +)[]; /** * Other controller actions RampsController calls via the messenger. diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index c7f4c2add68..1c325de88b8 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -21,6 +21,9 @@ }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" + }, + { + "path": "../kyc-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index f85e8ef6394..edb3ae546cb 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -19,6 +19,9 @@ }, { "path": "../controller-utils" + }, + { + "path": "../kyc-controller" } ], "include": ["../../types", "./src"] From 5311fe8f85c2526320362a3c833dfcec9c70ab52 Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 12:39:45 -0600 Subject: [PATCH 60/67] feat(kyc-controller): register Money Account wallets Add an address-only KycController action that resolves the MoonPay customer, signs a Monad ownership proof, and registers the wallet through the MetaMask proxy. --- packages/kyc-controller/CHANGELOG.md | 2 + packages/kyc-controller/package.json | 1 + .../src/KycController-method-action-types.ts | 19 + .../kyc-controller/src/KycController.test.ts | 223 ++++++++ packages/kyc-controller/src/KycController.ts | 188 ++++++- .../src/KycService-method-action-types.ts | 37 ++ .../kyc-controller/src/KycService.test.ts | 55 ++ packages/kyc-controller/src/KycService.ts | 94 +++- packages/kyc-controller/src/index.test.ts | 1 + packages/kyc-controller/src/index.ts | 12 + .../src/ownership-message.test.ts | 66 +++ .../kyc-controller/src/ownership-message.ts | 32 ++ .../src/wallet-registration-machine.test.ts | 297 ++++++++++ .../src/wallet-registration-machine.ts | 221 ++++++++ .../src/wallet-registration-service.test.ts | 530 ++++++++++++++++++ .../src/wallet-registration-service.ts | 372 ++++++++++++ yarn.lock | 1 + 17 files changed, 2140 insertions(+), 11 deletions(-) create mode 100644 packages/kyc-controller/src/ownership-message.test.ts create mode 100644 packages/kyc-controller/src/ownership-message.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-machine.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.test.ts create mode 100644 packages/kyc-controller/src/wallet-registration-service.ts diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 862fdea025b..c719e9bbe6c 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. - Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852)) +- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847)) +- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847)) - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 5b0c23f7fff..22967e56612 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -61,6 +61,7 @@ "@metamask/base-data-service": "^0.1.3", "@metamask/controller-utils": "^12.3.0", "@metamask/geolocation-controller": "^1.0.0", + "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/superstruct": "^3.4.1", diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index b2aa85cca42..1f20102648f 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -215,6 +215,24 @@ export type KycControllerGetSessionStatusAction = { handler: KycController['getSessionStatus']; }; +/** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type KycControllerRegisterMoneyAccountWalletAction = { + type: `KycController:registerMoneyAccountWallet`; + handler: KycController['registerMoneyAccountWallet']; +}; + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. @@ -243,4 +261,5 @@ export type KycControllerMethodActions = | KycControllerStartSumSubAction | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction + | KycControllerRegisterMoneyAccountWalletAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index aef1d4f36e9..db43d30b8a7 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -15,6 +15,7 @@ import type { KycControllerMessenger } from './KycController.js'; import type { KycSumSubLauncher } from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of @@ -1778,6 +1779,189 @@ describe('KycController', () => { }); }); + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('prefers the customer id captured from the MoonPay frame', async () => { + await withController( + { options: { state: { moonpayCustomerId: 'frame-customer' } } }, + async ({ controller, handlers }) => { + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer frame-customer.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'frame-customer', + signature: '0xsig', + }), + ); + }, + ); + }); + + it('falls back to resolving the customer id from the proxy', async () => { + await withController(async ({ controller, handlers }) => { + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + customerId: 'iron-customer-fallback', + }), + ); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, handlers }) => { + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, handlers }) => { + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows an initial lookup failure without signing', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('lookup failed'); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, handlers }) => { + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + describe('reset', () => { it('clears session state but preserves persisted terms', async () => { await withController( @@ -2500,6 +2684,10 @@ type ServiceHandlers = { createUkycSession: jest.Mock; createJourney: jest.Mock; getSessionStatus: jest.Mock; + getMoonpayCustomerId: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; performGetStorage: jest.Mock; performSetStorage: jest.Mock; }; @@ -2535,6 +2723,10 @@ const SERVICE_ACTIONS = [ 'KycService:createUkycSession', 'KycService:createJourney', 'KycService:getSessionStatus', + 'KycService:getMoonpayCustomerId', + 'KycService:getWalletRegistrationStatus', + 'KycService:registerSelfHostedWallet', + 'KeyringController:signPersonalMessage', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; @@ -2619,6 +2811,21 @@ function withController( .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), + getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration: { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad', + disabled: false, + isSelf: true, + }, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), performGetStorage: jest.fn().mockResolvedValue(null), performSetStorage: jest.fn().mockResolvedValue(undefined), }; @@ -2678,6 +2885,22 @@ function withController( 'KycService:getSessionStatus', handlers.getSessionStatus, ); + rootMessenger.registerActionHandler( + 'KycService:getMoonpayCustomerId', + handlers.getMoonpayCustomerId, + ); + rootMessenger.registerActionHandler( + 'KycService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'KycService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); rootMessenger.registerActionHandler( 'UserStorageController:performGetStorage', handlers.performGetStorage, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 29e7f21e12e..41da4dab8d5 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -4,12 +4,13 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; +import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { UserStorageControllerPerformGetStorageAction, UserStorageControllerPerformSetStorageAction, } from '@metamask/profile-sync-controller/user-storage'; -import type { Json } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; @@ -17,6 +18,7 @@ import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; +import { buildOwnershipMessage } from './ownership-message.js'; import type { KycCustomerIdentity, KycDisclaimer, @@ -34,6 +36,15 @@ import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; +import { + createInitialState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -380,6 +391,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshKycStatus', 'startSumSub', 'getSessionStatus', + 'registerMoneyAccountWallet', 'reset', ] as const; @@ -394,6 +406,7 @@ export type KycControllerActions = type AllowedActions = | KycServiceMethodActions + | KeyringControllerSignPersonalMessageAction | UserStorageControllerPerformGetStorageAction | UserStorageControllerPerformSetStorageAction; @@ -453,6 +466,16 @@ export type KycControllerOptions = { userStatusPollIntervalMs?: number; }; +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + /** * The shape of a message posted by a Check/Auth frame. */ @@ -1814,6 +1837,169 @@ export class KycController extends BaseController< } } + /** + * Registers a Money Account wallet with MoonPay Iron. + * + * Consumers provide only the Monad address. The controller reuses the Iron + * customer id captured from MoonPay's hosted frame when available, otherwise + * it resolves the id from the authenticated MetaMask profile via KycService. + * Message construction, signing, submission, and ambiguous-write + * reconciliation stay internal to KYC. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: Hex; + }): Promise { + let machine = transitionWalletRegistration(createInitialState(), { + type: 'START', + }); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'KycService:getWalletRegistrationStatus', + { address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'KycService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 7f38f86aa14..eb0b35f3875 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -17,6 +17,40 @@ export type KycServiceGetGeoCountryAction = { handler: KycService['getGeoCountry']; }; +/** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ +export type KycServiceGetMoonpayCustomerIdAction = { + type: `KycService:getMoonpayCustomerId`; + handler: KycService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type KycServiceGetWalletRegistrationStatusAction = { + type: `KycService:getWalletRegistrationStatus`; + handler: KycService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type KycServiceRegisterSelfHostedWalletAction = { + type: `KycService:registerSelfHostedWallet`; + handler: KycService['registerSelfHostedWallet']; +}; + /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -190,6 +224,9 @@ export type KycServiceGetSessionStatusAction = { */ export type KycServiceMethodActions = | KycServiceGetGeoCountryAction + | KycServiceGetMoonpayCustomerIdAction + | KycServiceGetWalletRegistrationStatusAction + | KycServiceRegisterSelfHostedWalletAction | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index c7d5d6c9db9..62fb93bfe10 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -59,6 +59,61 @@ describe('KycService', () => { }); }); + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/customer') + .matchHeader('authorization', 'Bearer test-bearer') + .reply(200, { customerId: 'iron-customer-1' }); + + const { service } = getService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/self-hosted-wallets') + .reply(200, []); + + const { service } = getService(); + + expect( + await service.getWalletRegistrationStatus({ address: '0xabc' }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/self-hosted-wallets', { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const { service } = getService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + }); + describe('fetchDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index e155ca8f386..09686c098a5 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -35,6 +35,11 @@ import type { import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; // === GENERAL === @@ -60,6 +65,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'createUkycSession', 'createJourney', 'getSessionStatus', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', ] as const; /** @@ -303,6 +311,13 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; +}; + // === SERVICE DEFINITION === /** @@ -328,6 +343,8 @@ export class KycService extends BaseDataService< readonly #fractalEncryptionBaseUrl: string; + readonly #walletRegistrationService: WalletRegistrationService; + /** * Constructs a new KycService. * @@ -362,6 +379,11 @@ export class KycService extends BaseDataService< } this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; + this.#walletRegistrationService = new WalletRegistrationService({ + fetch: fetchFunction, + baseUrl, + getAuthToken: async (): Promise => this.#getBearerToken(), + }); this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -401,6 +423,49 @@ export class KycService extends BaseDataService< return alpha3; } + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#walletRegistrationService.getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered. + * + * @param params - The address to check. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + address, + }: { + address: string; + }): Promise { + return await this.#walletRegistrationService.getRegistrationStatus({ + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#walletRegistrationService.registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -835,6 +900,24 @@ export class KycService extends BaseDataService< } } + /** + * Gets the authenticated wallet bearer token. + * + * @returns The bearer token. + */ + async #getBearerToken(): Promise { + const bearerToken = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token - is the wallet signed in?', + ); + } + return bearerToken; + } + /** * Performs a single JSON request. * @@ -867,16 +950,7 @@ export class KycService extends BaseDataService< } if (authenticated) { - const bearerToken = await this.messenger.call( - 'AuthenticationController:getBearerToken', - ); - assert(bearerToken, string()); - if (!bearerToken) { - throw new Error( - 'Unable to obtain an authentication bearer token — is the wallet signed in?', - ); - } - headers.Authorization = `Bearer ${bearerToken}`; + headers.Authorization = `Bearer ${await this.#getBearerToken()}`; } const response = await this.#fetch(url.toString(), { diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index f986f8847a4..3bbd3b4c258 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -12,6 +12,7 @@ describe('@metamask/kyc-controller', () => { alpha2ToAlpha3: expect.any(Function), generateKeyPair: expect.any(Function), decryptCredentials: expect.any(Function), + WalletRegistrationError: expect.any(Function), controllerName: 'KycController', serviceName: 'KycService', }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index d6b24b3730a..34ea0817c92 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -8,6 +8,7 @@ export type { KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, + MoneyAccountWalletRegistrationResult, KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, @@ -29,6 +30,7 @@ export type { KycControllerLoadDisclaimersAction, KycControllerRefreshKycStatusAction, KycControllerResetAction, + KycControllerRegisterMoneyAccountWalletAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -50,6 +52,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + RegisterSelfHostedWalletParams, SubmitConsentsParams, UkycSessionResponse, WrappedEncryptionKey, @@ -69,6 +72,9 @@ export type { KycServiceGetGeoCountryAction, KycServiceGetSessionStatusAction, KycServiceGetWrappingKeyAction, + KycServiceGetMoonpayCustomerIdAction, + KycServiceGetWalletRegistrationStatusAction, + KycServiceRegisterSelfHostedWalletAction, KycServiceSubmitConsentsAction, } from './KycService-method-action-types.js'; @@ -134,3 +140,9 @@ export type { MintedUkycTestToken, MintUkycTestTokenParams, } from './ukyc/testToken.js'; + +export type { + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/kyc-controller/src/ownership-message.test.ts new file mode 100644 index 00000000000..071144a4642 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.test.ts @@ -0,0 +1,66 @@ +import { buildOwnershipMessage } from './ownership-message.js'; + +describe('buildOwnershipMessage', () => { + it('builds the exact MoonPay ownership sentence', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf1234567890', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe( + 'I am verifying ownership of the wallet address 0xAbCdEf1234567890 as customer customer-123. This message was signed on 12/08/2026 to confirm my control over this wallet.', + ); + }); + + it('formats the date in UTC across a local date boundary', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2027-01-01T00:30:00.000Z'), + }); + + expect(result).toContain('signed on 01/01/2027'); + }); + + it('preserves the exact supplied address casing', () => { + const result = buildOwnershipMessage({ + address: '0xAbCdEf', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toContain('wallet address 0xAbCdEf as customer'); + }); + + it('does not add surrounding whitespace or a trailing newline', () => { + const result = buildOwnershipMessage({ + address: '0x1234', + customerId: 'customer-123', + now: new Date('2026-08-12T15:30:00.000Z'), + }); + + expect(result).toBe(result.trim()); + expect(result.endsWith('\n')).toBe(false); + }); + + it('builds a fresh message after UTC midnight', () => { + const request = { + address: '0x1234', + customerId: 'customer-123', + }; + + const beforeMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-12T23:59:59.999Z'), + }); + const afterMidnight = buildOwnershipMessage({ + ...request, + now: new Date('2026-08-13T00:00:00.000Z'), + }); + + expect(beforeMidnight).toContain('signed on 12/08/2026'); + expect(afterMidnight).toContain('signed on 13/08/2026'); + expect(afterMidnight).not.toBe(beforeMidnight); + }); +}); diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/kyc-controller/src/ownership-message.ts new file mode 100644 index 00000000000..539d5e3aac3 --- /dev/null +++ b/packages/kyc-controller/src/ownership-message.ts @@ -0,0 +1,32 @@ +export type BuildOwnershipMessageRequest = { + address: string; + customerId: string; + now: Date; +}; + +/** + * Builds the proof-of-ownership message required to register a self-hosted + * wallet with MoonPay Iron (`POST /addresses/crypto/selfhosted`). + * + * The returned string is the exact sentence that must be both signed (EIP-191 + * `personal_sign`) and sent, byte-for-byte, in the registration request body. + * The date is always formatted as `DD/MM/YYYY` in UTC so a signature produced + * just before UTC midnight is not reused with a stale date after rollover. + * + * @param request - Values embedded in the ownership message. + * @param request.address - Wallet address, kept verbatim (no re-casing). + * @param request.customerId - Iron customer id; must match the request body. + * @param request.now - Reference time used to derive the UTC calendar date. + * @returns The exact message to sign and submit. + */ +export function buildOwnershipMessage({ + address, + customerId, + now, +}: BuildOwnershipMessageRequest): string { + const day = String(now.getUTCDate()).padStart(2, '0'); + const month = String(now.getUTCMonth() + 1).padStart(2, '0'); + const year = now.getUTCFullYear(); + + return `I am verifying ownership of the wallet address ${address} as customer ${customerId}. This message was signed on ${day}/${month}/${year} to confirm my control over this wallet.`; +} diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/kyc-controller/src/wallet-registration-machine.test.ts new file mode 100644 index 00000000000..de58a81ef12 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.test.ts @@ -0,0 +1,297 @@ +import { + createInitialState, + transition, +} from './wallet-registration-machine.js'; +import type { + WalletRegistrationEvent, + WalletRegistrationState, +} from './wallet-registration-machine.js'; + +const run = ( + events: WalletRegistrationEvent[], + initial: WalletRegistrationState = createInitialState(), +): WalletRegistrationState => + events.reduce((state, event) => transition(state, event), initial); + +describe('wallet registration machine: lookup', () => { + it('starts idle', () => { + expect(createInitialState().status).toBe('idle'); + }); + + it('start moves idle to preparing', () => { + expect(run([{ type: 'START' }]).status).toBe('preparing'); + }); + + it('an active existing registration skips signing and completes', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ACTIVE' }]); + expect(state.status).toBe('alreadyRegistered'); + }); + + it('a disabled existing registration enters registeredDisabled', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_DISABLED' }]); + expect(state.status).toBe('registeredDisabled'); + }); + + it('an absent registration proceeds to signing', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(state.status).toBe('signing'); + }); + + it('a failed lookup enters lookupUnavailable and never assumes absent', () => { + const state = run([{ type: 'START' }, { type: 'LOOKUP_FAILED' }]); + expect(state.status).toBe('lookupUnavailable'); + }); +}); + +describe('wallet registration machine: signing', () => { + const atSigning = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + + it('a locked keyring during signing waits then resumes the same attempt', () => { + const locked = transition(atSigning(), { type: 'WALLET_LOCKED' }); + expect(locked.status).toBe('awaitingUnlock'); + + const resumed = transition(locked, { type: 'WALLET_UNLOCKED' }); + expect(resumed.status).toBe('signing'); + }); + + it('successful signing moves to submitting', () => { + expect(transition(atSigning(), { type: 'SIGN_OK' }).status).toBe( + 'submitting', + ); + }); + + it('explicit user rejection reaches cancelled', () => { + expect(transition(atSigning(), { type: 'SIGN_REJECTED' }).status).toBe( + 'cancelled', + ); + }); + + it('classifies signing failures as retryable or terminal', () => { + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: true }).status, + ).toBe('failedRetryable'); + expect( + transition(atSigning(), { type: 'SIGN_FAILED', retryable: false }).status, + ).toBe('failedTerminal'); + }); + + it('cancellation during signing aborts without failing', () => { + expect(transition(atSigning(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: submitting outcomes', () => { + const atSubmitting = (): WalletRegistrationState => + run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }, { type: 'SIGN_OK' }]); + + it('200 reaches registered', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_OK' }).status).toBe( + 'registered', + ); + }); + + it('any 409 enters disambiguate409', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_CONFLICT', + }).status, + ).toBe('disambiguate409'); + }); + + it('timeout / 5xx enters checkThenRetry', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_TRANSIENT' }).status, + ).toBe('checkThenRetry'); + }); + + it('a UTC-rollover 400 rebuilds and re-signs once', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: true, + }).status, + ).toBe('signing'); + }); + + it('a non-rollover 400 is terminal', () => { + expect( + transition(atSubmitting(), { + type: 'SUBMIT_VALIDATION', + utcRollover: false, + }).status, + ).toBe('failedTerminal'); + }); + + it('401 / 403 / 404 are terminal', () => { + expect(transition(atSubmitting(), { type: 'SUBMIT_TERMINAL' }).status).toBe( + 'failedTerminal', + ); + }); + + it('429 becomes retryable', () => { + expect( + transition(atSubmitting(), { type: 'SUBMIT_RATE_LIMITED' }).status, + ).toBe('failedRetryable'); + }); + + it('cancellation during submitting aborts without failing', () => { + expect(transition(atSubmitting(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: 409 disambiguation', () => { + const atDisambiguate = (): WalletRegistrationState => + run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_CONFLICT' }, + ]); + + it('an active list match after 409 completes as alreadyRegistered', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled list match after 409 enters registeredDisabled', () => { + expect( + transition(atDisambiguate(), { type: 'LOOKUP_DISABLED' }).status, + ).toBe('registeredDisabled'); + }); + + it('a 409 plus GET miss is retryable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'failedRetryable', + ); + }); + + it('a failed GET during disambiguation is lookupUnavailable', () => { + expect(transition(atDisambiguate(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('cancellation during disambiguation does not become a failure', () => { + expect(transition(atDisambiguate(), { type: 'CANCEL' }).status).toBe( + 'cancelled', + ); + }); +}); + +describe('wallet registration machine: checkThenRetry after 5xx/timeout', () => { + const atCheck = ( + initial?: WalletRegistrationState, + ): WalletRegistrationState => + run( + [ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_TRANSIENT' }, + ], + initial, + ); + + it('a GET showing the resource completes without another POST', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ACTIVE' }).status).toBe( + 'alreadyRegistered', + ); + }); + + it('a disabled GET result enters registeredDisabled', () => { + expect(transition(atCheck(), { type: 'LOOKUP_DISABLED' }).status).toBe( + 'registeredDisabled', + ); + }); + + it('an absent GET result retries signing within the attempt ceiling', () => { + expect(transition(atCheck(), { type: 'LOOKUP_ABSENT' }).status).toBe( + 'signing', + ); + }); + + it('a failed GET during reconciliation is lookupUnavailable', () => { + expect(transition(atCheck(), { type: 'LOOKUP_FAILED' }).status).toBe( + 'lookupUnavailable', + ); + }); + + it('stops retrying once the attempt ceiling is reached', () => { + let state = createInitialState(); + state = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }], state); + // Loop sign -> transient -> absent until the ceiling flips to retryable. + for (let i = 0; i < 5; i++) { + if (state.status === 'signing') { + state = transition(state, { type: 'SIGN_OK' }); + state = transition(state, { type: 'SUBMIT_TRANSIENT' }); + state = transition(state, { type: 'LOOKUP_ABSENT' }); + } + } + expect(state.status).toBe('failedRetryable'); + }); + + it('cancellation during checkThenRetry does not become a failure', () => { + expect(transition(atCheck(), { type: 'CANCEL' }).status).toBe('cancelled'); + }); +}); + +describe('wallet registration machine: retry, resume, and concurrency', () => { + it('retry from failedRetryable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_RATE_LIMITED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from lookupUnavailable re-checks server state via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_FAILED' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('retry from cancelled restarts via preparing', () => { + const state = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'CANCEL' }, + { type: 'RETRY' }, + ]); + expect(state.status).toBe('preparing'); + }); + + it('a second START while in-flight is ignored (one operation)', () => { + const inFlight = run([{ type: 'START' }, { type: 'LOOKUP_ABSENT' }]); + expect(inFlight.status).toBe('signing'); + expect(transition(inFlight, { type: 'START' }).status).toBe('signing'); + }); + + it('ignores events that do not apply to the current state', () => { + const preparing = run([{ type: 'START' }]); + expect(transition(preparing, { type: 'SUBMIT_OK' }).status).toBe( + 'preparing', + ); + }); + + it('terminal success states ignore further events', () => { + const registered = run([ + { type: 'START' }, + { type: 'LOOKUP_ABSENT' }, + { type: 'SIGN_OK' }, + { type: 'SUBMIT_OK' }, + ]); + expect(transition(registered, { type: 'RETRY' }).status).toBe('registered'); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/kyc-controller/src/wallet-registration-machine.ts new file mode 100644 index 00000000000..3251c56d02e --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-machine.ts @@ -0,0 +1,221 @@ +/** + * Pure, hand-rolled finite state machine for the MoonPay Iron self-hosted + * wallet registration signing step. It follows the FSM convention used + * elsewhere in `core` (no XState dependency): a single pure `transition` + * reducer plus a data-driven transition table. + * + * Side effects (server lookups, signing, POSTing) live in the interpreter that + * drives this machine; every effect result is fed back in as an event, so the + * machine itself stays deterministic and trivially testable. + */ + +/** Every state in the signing step. */ +export type WalletRegistrationStatus = + | 'idle' + | 'preparing' + | 'awaitingUnlock' + | 'signing' + | 'submitting' + | 'disambiguate409' + | 'checkThenRetry' + | 'lookupUnavailable' + | 'registered' + | 'alreadyRegistered' + | 'registeredDisabled' + | 'failedRetryable' + | 'failedTerminal' + | 'cancelled'; + +/** Machine context carried across transitions. */ +export type WalletRegistrationContext = { + /** Number of sign attempts made so far (used for the retry ceiling). */ + attempts: number; + /** Maximum number of sign attempts before a retryable failure is surfaced. */ + maxAttempts: number; +}; + +export type WalletRegistrationState = { + status: WalletRegistrationStatus; + context: WalletRegistrationContext; +}; + +/** Events the interpreter dispatches into the machine. */ +export type WalletRegistrationEvent = + | { type: 'START' } + | { type: 'WALLET_LOCKED' } + | { type: 'WALLET_UNLOCKED' } + | { type: 'LOOKUP_ACTIVE' } + | { type: 'LOOKUP_DISABLED' } + | { type: 'LOOKUP_ABSENT' } + | { type: 'LOOKUP_FAILED' } + | { type: 'SIGN_OK' } + | { type: 'SIGN_REJECTED' } + | { type: 'SIGN_FAILED'; retryable: boolean } + | { type: 'SUBMIT_OK' } + | { type: 'SUBMIT_CONFLICT' } + | { type: 'SUBMIT_TRANSIENT' } + | { type: 'SUBMIT_VALIDATION'; utcRollover: boolean } + | { type: 'SUBMIT_TERMINAL' } + | { type: 'SUBMIT_RATE_LIMITED' } + | { type: 'RETRY' } + | { type: 'CANCEL' }; + +type EventType = WalletRegistrationEvent['type']; + +type Handler = ( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +) => WalletRegistrationState; + +const DEFAULT_MAX_ATTEMPTS = 3; + +/** + * Creates the initial idle state. + * + * @param maxAttempts - Optional retry ceiling for sign attempts. + * @returns A fresh idle machine state. + */ +export function createInitialState( + maxAttempts: number = DEFAULT_MAX_ATTEMPTS, +): WalletRegistrationState { + return { status: 'idle', context: { attempts: 0, maxAttempts } }; +} + +/** + * Builds a handler that moves to a status while preserving context. + * + * @param status - Target status. + * @returns A handler transitioning to `status`. + */ +function keep(status: WalletRegistrationStatus): Handler { + return (state) => ({ status, context: state.context }); +} + +/** + * Builds a handler that moves to a status and resets the retry context. Used + * when the user (or app resume) starts a fresh attempt from scratch. + * + * @param status - Target status. + * @returns A handler transitioning to `status` with reset context. + */ +function reset(status: WalletRegistrationStatus): Handler { + return (state) => ({ + status, + context: { ...state.context, attempts: 0 }, + }); +} + +/** + * Moves to `signing` and counts this as a new sign attempt. + * + * @param state - Current state. + * @returns The `signing` state with an incremented attempt count. + */ +const toSigning: Handler = (state) => ({ + status: 'signing', + context: { ...state.context, attempts: state.context.attempts + 1 }, +}); + +const toPreparing = reset('preparing'); +const toAlreadyRegistered = keep('alreadyRegistered'); +const toRegisteredDisabled = keep('registeredDisabled'); +const toLookupUnavailable = keep('lookupUnavailable'); +const toCancelled = keep('cancelled'); + +const signFailed: Handler = (state, event) => { + const { retryable } = event as Extract< + WalletRegistrationEvent, + { type: 'SIGN_FAILED' } + >; + return retryable + ? keep('failedRetryable')(state, event) + : keep('failedTerminal')(state, event); +}; + +const submitValidation: Handler = (state, event) => { + const { utcRollover } = event as Extract< + WalletRegistrationEvent, + { type: 'SUBMIT_VALIDATION' } + >; + return utcRollover && state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedTerminal')(state, event); +}; + +const checkThenRetryAbsent: Handler = (state, event) => + state.context.attempts < state.context.maxAttempts + ? toSigning(state, event) + : keep('failedRetryable')(state, event); + +const TABLE: Partial< + Record>> +> = { + idle: { + START: toPreparing, + }, + preparing: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: toSigning, + LOOKUP_FAILED: toLookupUnavailable, + }, + awaitingUnlock: { + WALLET_UNLOCKED: keep('signing'), + }, + signing: { + SIGN_OK: keep('submitting'), + SIGN_REJECTED: toCancelled, + SIGN_FAILED: signFailed, + WALLET_LOCKED: keep('awaitingUnlock'), + CANCEL: toCancelled, + }, + submitting: { + SUBMIT_OK: keep('registered'), + SUBMIT_CONFLICT: keep('disambiguate409'), + SUBMIT_TRANSIENT: keep('checkThenRetry'), + SUBMIT_VALIDATION: submitValidation, + SUBMIT_TERMINAL: keep('failedTerminal'), + SUBMIT_RATE_LIMITED: keep('failedRetryable'), + CANCEL: toCancelled, + }, + disambiguate409: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: keep('failedRetryable'), + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + checkThenRetry: { + LOOKUP_ACTIVE: toAlreadyRegistered, + LOOKUP_DISABLED: toRegisteredDisabled, + LOOKUP_ABSENT: checkThenRetryAbsent, + LOOKUP_FAILED: toLookupUnavailable, + CANCEL: toCancelled, + }, + failedRetryable: { + RETRY: toPreparing, + }, + lookupUnavailable: { + RETRY: toPreparing, + }, + cancelled: { + RETRY: toPreparing, + }, +}; + +/** + * Pure transition reducer. Unhandled (state, event) pairs are no-ops, which is + * how the machine enforces "one in-flight operation" (a second `START` while + * busy is ignored) and how terminal states stay put. + * + * @param state - Current machine state. + * @param event - Event to apply. + * @returns The next state (or the same state for unhandled events). + */ +export function transition( + state: WalletRegistrationState, + event: WalletRegistrationEvent, +): WalletRegistrationState { + const handler = TABLE[state.status]?.[event.type]; + return handler ? handler(state, event) : state; +} diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts new file mode 100644 index 00000000000..a8ee7540d23 --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -0,0 +1,530 @@ +import { + WalletRegistrationError, + WalletRegistrationService, +} from './wallet-registration-service.js'; + +const BASE_URL = 'https://proxy.metamask.test'; +const AUTH_TOKEN = 'session-jwt-abc'; + +type FetchInit = { + method?: string; + headers: Record; + body?: string; +}; + +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + signal?: unknown; + }, +) => Promise; + +const jsonResponse = (status: number, body: unknown): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => body, + text: async (): Promise => JSON.stringify(body), +}); + +const textResponse = (status: number, body: string): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => JSON.parse(body), + text: async (): Promise => body, +}); + +const invalidJsonResponse = (status: number): HttpResponse => ({ + ok: status >= 200 && status < 300, + status, + json: async (): Promise => { + throw new Error('invalid json'); + }, + text: async (): Promise => 'not json', +}); + +const buildService = (fetchImpl: FetchLike): WalletRegistrationService => + new WalletRegistrationService({ + fetch: fetchImpl, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + }); + +const verifiedAddress = ( + overrides: Record = {}, +): Record => ({ + id: 'addr-1', + wallet_address: '0xAbC0000000000000000000000000000000000001', + blockchain: 'Monad', + address_type: 'SelfHosted', + disabled: false, + is_self: true, + proof_message: 'I am verifying ownership...', + proof_signature: '0xsig', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; + +describe('WalletRegistrationService.getMoonpayCustomerId', () => { + it('returns the Iron customer id from the authenticated proxy lookup', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { customerId: 'iron-customer-1' }), + ); + + expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( + 'iron-customer-1', + ); + + expect(fetchMock).toHaveBeenCalledWith( + `${BASE_URL}/vendors/moonpay/customer`, + expect.objectContaining({ + method: 'GET', + headers: expect.objectContaining({ + authorization: `Bearer ${AUTH_TOKEN}`, + }), + }), + ); + }); + + it('maps a failed customer lookup to a typed HTTP error', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(404, { code: 'iron_error', message: 'not found' }), + ); + + await expect( + buildService(fetchMock).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + }); + + it('rejects malformed customer lookup responses', async () => { + await expect( + buildService( + jest.fn(async (): Promise => invalidJsonResponse(200)), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + + await expect( + buildService( + jest.fn(async (): Promise => jsonResponse(200, {})), + ).getMoonpayCustomerId(), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); + +describe('WalletRegistrationService.getRegistrationStatus', () => { + it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, []), + ); + const service = buildService(fetchMock); + + await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('GET'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + }); + + it('returns an active match parsed from wallet_address', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, [verifiedAddress()]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: '0xabc0000000000000000000000000000000000001', + blockchain: 'Monad', + }); + + expect(status).toMatchObject({ + type: 'active', + registration: { address: EVM_ADDRESS, disabled: false }, + }); + }); + + it('returns a disabled result when the matching address is disabled', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ disabled: true })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('disabled'); + }); + + it('scopes matching per blockchain (same address, different chain is absent)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [verifiedAddress({ blockchain: 'Ethereum' })]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('absent'); + }); + + it('skips entries whose wallet_address is not a string', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, [ + { id: 'junk', wallet_address: 12345, blockchain: 'Monad' }, + verifiedAddress(), + ]), + ); + const service = buildService(fetchMock); + + const status = await service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }); + + expect(status.type).toBe('active'); + }); + + it('throws malformedResponse when the list body is not valid JSON', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('throws a lookupUnavailable error on a non-2xx list response', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(500, 'boom'), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('throws a lookupUnavailable error when the list body is malformed', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toBeInstanceOf(WalletRegistrationError); + }); + + it('never converts a network failure during lookup into "absent"', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('network down')); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); + + it('handles a non-Error thrown during lookup', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('string failure'); + const service = buildService(fetchMock); + + await expect( + service.getRegistrationStatus({ + address: EVM_ADDRESS, + blockchain: 'Monad', + }), + ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + }); +}); + +const registerRequest = { + customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + address: EVM_ADDRESS, + blockchain: 'Monad' as const, + message: 'I am verifying ownership ...', + signature: '0xdeadbeef', +}; + +const selfHostedResponse = ( + overrides: Record = {}, +): Record => ({ + id: 'wallet-1', + address: EVM_ADDRESS, + customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + disabled: false, + signature: '0xdeadbeef', + created_at: '2026-08-12T10:00:00Z', + ...overrides, +}); + +const errorEnvelope = (status: number, message: string): HttpResponse => + jsonResponse(status, { + code: 'iron_error', + message, + }); + +describe('WalletRegistrationService.registerSelfHostedWallet', () => { + it('sends the five contract fields via POST and returns registered on 200', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + const outcome = await service.registerSelfHostedWallet(registerRequest); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).not.toContain('iron.xyz'); + expect(init.method).toBe('POST'); + expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ + customer_id: registerRequest.customerId, + address: registerRequest.address, + blockchain: 'Monad', + message: registerRequest.message, + signature: registerRequest.signature, + }); + expect(outcome.registration).toMatchObject({ + id: 'wallet-1', + address: registerRequest.address, + disabled: false, + }); + }); + + it('does not send an idempotency key (the backend derives it)', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, selfHostedResponse()), + ); + const service = buildService(fetchMock); + + await service.registerSelfHostedWallet(registerRequest); + + const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; + const headerKeys = Object.keys(init.headers).map((key) => + key.toLowerCase(), + ); + expect(headerKeys).not.toContain('idempotency-key'); + expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + }); + + it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + const fetchMock = jest.fn( + async (): Promise => + errorEnvelope( + 409, + 'A crypto address with this wallet address already exists', + ), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'conflict', + httpStatus: 409, + body: 'A crypto address with this wallet address already exists', + }); + }); + + it('maps 5xx to a transient error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(500, 'internal error'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient', httpStatus: 500 }); + }); + + it('maps a network failure / timeout to a transient error', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue(new Error('ETIMEDOUT')); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps a non-Error thrown during registration to transient', async () => { + const fetchMock = jest + .fn, unknown[]>() + .mockRejectedValue('socket hang up'); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'transient' }); + }); + + it('maps 400 to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(400, 'bad message'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 400 }); + }); + + it('maps an unmapped 4xx (422) to a validation error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(422, 'unprocessable'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', httpStatus: 422 }); + }); + + it('maps 401 to unauthorized', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(401, 'session expired'), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'unauthorized' }); + }); + + it('maps 403 to forbidden and 404 to notFound', async () => { + const forbiddenFetch = jest.fn( + async (): Promise => errorEnvelope(403, 'suspended'), + ); + const notFoundFetch = jest.fn( + async (): Promise => errorEnvelope(404, 'not found'), + ); + + const forbidden = await buildService(forbiddenFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + const notFound = await buildService(notFoundFetch) + .registerSelfHostedWallet(registerRequest) + .catch((error: unknown): WalletRegistrationError => { + return error as WalletRegistrationError; + }); + + expect(forbidden).toMatchObject({ kind: 'forbidden' }); + expect(notFound).toMatchObject({ kind: 'notFound' }); + }); + + it('maps an error envelope without a code', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(403, { message: 'forbidden' }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'forbidden' }); + }); + + it('maps 429 to a rateLimited error', async () => { + const fetchMock = jest.fn( + async (): Promise => errorEnvelope(429, 'slow down'), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'rateLimited' }); + }); + + it('maps a malformed 200 body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => jsonResponse(200, { nope: true }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('rejects a success body that has an id but no address', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(200, { id: 'wallet-1', disabled: false }), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON success body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(200), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); + + it('maps a non-JSON error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => invalidJsonResponse(400), + ); + const service = buildService(fetchMock); + + await expect( + service.registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse' }); + }); +}); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts new file mode 100644 index 00000000000..08d118d3b8c --- /dev/null +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -0,0 +1,372 @@ +/** The only blockchain supported by the Money Account POC. */ +export type Blockchain = 'Monad'; + +/** Normalized view of a single registered self-hosted address. */ +export type SelfHostedRegistration = { + id: string; + address: string; + blockchain: Blockchain; + disabled: boolean; + isSelf: boolean; +}; + +/** Result of reconciling a wallet against the customer's registered addresses. */ +export type RegistrationStatus = + | { type: 'active'; registration: SelfHostedRegistration } + | { type: 'disabled'; registration: SelfHostedRegistration } + | { type: 'absent' }; + +/** + * Discriminated error kinds surfaced to the state machine. Every non-success + * path maps to exactly one of these so the machine can decide deterministically. + */ +export type WalletRegistrationErrorKind = + | 'validation' + | 'unauthorized' + | 'forbidden' + | 'notFound' + | 'conflict' + | 'rateLimited' + | 'transient' + | 'lookupUnavailable' + | 'malformedResponse'; + +/** Minimal HTTP response shape, so the service is environment-agnostic. */ +type HttpResponse = { + ok: boolean; + status: number; + json: () => Promise; + text: () => Promise; +}; + +/** Minimal `fetch`-like function the service depends on. */ +type FetchLike = ( + url: string, + init?: { + method?: string; + headers?: Record; + body?: string; + }, +) => Promise; + +/** Typed error carrying enough context for state transitions. */ +export class WalletRegistrationError extends Error { + readonly kind: WalletRegistrationErrorKind; + + readonly httpStatus?: number; + + readonly body?: string; + + constructor( + kind: WalletRegistrationErrorKind, + options: { + message?: string; + httpStatus?: number; + body?: string; + }, + ) { + super(options.message ?? `wallet registration failed: ${kind}`); + this.name = 'WalletRegistrationError'; + this.kind = kind; + this.httpStatus = options.httpStatus; + this.body = options.body; + } +} + +export type WalletRegistrationServiceOptions = { + fetch: FetchLike; + baseUrl: string; + getAuthToken: () => Promise; +}; + +export type GetRegistrationStatusRequest = { + address: string; + blockchain: Blockchain; +}; + +export type RegisterSelfHostedWalletRequest = { + customerId: string; + address: string; + blockchain: Blockchain; + message: string; + signature: string; +}; + +/** Successful registration outcome. */ +export type RegistrationOutcome = { + type: 'registered'; + registration: SelfHostedRegistration; +}; + +const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; +const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; + +/** + * Normalizes a Monad EVM address for case-insensitive comparison. + * + * @param address - Raw address string. + * @returns The comparison key for the address. + */ +function normalizeAddress(address: string): string { + return address.toLowerCase(); +} + +/** + * Maps an HTTP status to the typed error kind the state machine reacts to. + * + * @param status - HTTP status code from the proxy/Iron response. + * @returns The corresponding error kind. + */ +function mapStatusToKind(status: number): WalletRegistrationErrorKind { + switch (status) { + case 400: + return 'validation'; + case 401: + return 'unauthorized'; + case 403: + return 'forbidden'; + case 404: + return 'notFound'; + case 409: + return 'conflict'; + case 429: + return 'rateLimited'; + default: + return status >= 500 ? 'transient' : 'validation'; + } +} + +/** + * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * self-hosted wallet registration. It never calls Iron directly, so the Iron + * API key never ships in the client. + */ +export class WalletRegistrationService { + readonly #fetch: FetchLike; + + readonly #baseUrl: string; + + readonly #getAuthToken: () => Promise; + + constructor(options: WalletRegistrationServiceOptions) { + this.#fetch = options.fetch; + this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); + this.#getAuthToken = options.getAuthToken; + } + + /** + * Resolves Iron's internal customer id from the authenticated MetaMask + * profile. Used when the current KYC flow has not already received + * `customer.id` from MoonPay's hosted frame. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + const token = await this.#getAuthToken(); + const response = await this.#fetch( + `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }, + ); + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body was not valid JSON', + }); + } + + const { customerId } = payload as { customerId?: unknown }; + if (typeof customerId !== 'string' || customerId.length === 0) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MoonPay customer body missing customerId', + }); + } + return customerId; + } + + /** + * Reconciles a wallet against the customer's registered self-hosted addresses. + * A failed or malformed lookup is reported as `lookupUnavailable` and never + * downgraded to `absent`. + * + * @param request - Monad address to reconcile. + * @returns The active / disabled / absent status for the address. + */ + async getRegistrationStatus( + request: GetRegistrationStatusRequest, + ): Promise { + const { address, blockchain } = request; + + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'GET', + headers: { + accept: 'application/json', + authorization: `Bearer ${token}`, + }, + }); + } catch (error) { + throw new WalletRegistrationError('lookupUnavailable', { + message: 'self-hosted address lookup failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + const body = await response.text(); + throw new WalletRegistrationError('lookupUnavailable', { + httpStatus: response.status, + body, + }); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'self-hosted address list body was not valid JSON', + }); + } + if (!Array.isArray(payload)) { + throw new WalletRegistrationError('malformedResponse', { + message: 'expected an array of registered addresses', + }); + } + + const target = normalizeAddress(address); + const match = payload.find((entry) => { + const record = entry as Record; + const walletAddress = record.wallet_address; + if (typeof walletAddress !== 'string') { + return false; + } + return ( + normalizeAddress(walletAddress) === target && + record.blockchain === blockchain + ); + }) as Record | undefined; + + if (!match) { + return { type: 'absent' }; + } + + const registration = this.#toRegistration(match); + return registration.disabled + ? { type: 'disabled', registration } + : { type: 'active', registration }; + } + + /** + * Registers a self-hosted wallet through the MetaMask proxy. The proxy + * resolves the customer, derives the idempotency key, and attaches the API + * version, so the client never manages those. Every non-2xx response is + * mapped to a typed error; `409` is deliberately surfaced as an ambiguous + * `conflict` that the caller must reconcile with a follow-up status lookup. + * + * @param request - Customer id, address, blockchain, message, and signature. + * @returns The registered outcome on success. + */ + async registerSelfHostedWallet( + request: RegisterSelfHostedWalletRequest, + ): Promise { + let response: HttpResponse; + try { + const token = await this.#getAuthToken(); + response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), + }); + } catch (error) { + throw new WalletRegistrationError('transient', { + message: 'self-hosted registration request failed', + body: error instanceof Error ? error.message : undefined, + }); + } + + if (!response.ok) { + throw await this.#toHttpError(response); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body was not valid JSON', + }); + } + + const record = payload as Record; + if (typeof record.id !== 'string' || typeof record.address !== 'string') { + throw new WalletRegistrationError('malformedResponse', { + message: 'registration success body missing id/address', + }); + } + + return { + type: 'registered', + registration: { + id: record.id, + address: record.address, + blockchain: request.blockchain, + disabled: Boolean(record.disabled), + isSelf: true, + }, + }; + } + + async #toHttpError(response: HttpResponse): Promise { + let envelope: { message?: string }; + try { + envelope = (await response.json()) as { message?: string }; + } catch { + return new WalletRegistrationError('malformedResponse', { + httpStatus: response.status, + message: 'error body was not valid JSON', + }); + } + + const { status } = response; + const kind = mapStatusToKind(status); + return new WalletRegistrationError(kind, { + httpStatus: status, + body: envelope.message, + }); + } + + #toRegistration(record: Record): SelfHostedRegistration { + return { + id: String(record.id), + address: String(record.wallet_address), + blockchain: 'Monad', + disabled: Boolean(record.disabled), + isSelf: Boolean(record.is_self), + }; + } +} diff --git a/yarn.lock b/yarn.lock index 944b4e7b125..6e50507ac38 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7688,6 +7688,7 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" + "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.4.1" From a1b133dc464139bf6ca84fd22d7139ce7c7ebc0d Mon Sep 17 00:00:00 2001 From: Shane Austrie Date: Wed, 12 Aug 2026 14:02:34 -0600 Subject: [PATCH 61/67] feat(kyc-controller): sync wallet registration with neobank-proxy Retarget Money Account self-hosted wallet registration from kyc-api /vendors/moonpay/* to onramp-api neobank-proxy /neobank/... so Core matches Money Movement ownership and transparent proxy semantics. --- packages/kyc-controller/CHANGELOG.md | 6 +- .../src/KycController-method-action-types.ts | 6 +- .../kyc-controller/src/KycController.test.ts | 17 +- packages/kyc-controller/src/KycController.ts | 32 ++- .../src/KycService-method-action-types.ts | 10 +- .../kyc-controller/src/KycService.test.ts | 77 +++++- packages/kyc-controller/src/KycService.ts | 69 ++++- packages/kyc-controller/src/index.ts | 1 + .../src/wallet-registration-service.test.ts | 260 ++++++++++++++---- .../src/wallet-registration-service.ts | 171 +++++++++--- 10 files changed, 514 insertions(+), 135 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index c719e9bbe6c..ec6129b1a94 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -11,8 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. - Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852)) -- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask proxy ([#9847](https://github.com/MetaMask/core/pull/9847)) -- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9847](https://github.com/MetaMask/core/pull/9847)) +- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask neobank-proxy ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) +- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) + - Targets transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`), client-side Monad filtering, `Idempotency-Key`, and upstream error bodies mirrored 1:1. + - Optional `neobankBaseUrl` on `KycService` so KYC and wallet registration can use different hosts. - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 1f20102648f..4a837f24527 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -216,11 +216,13 @@ export type KycControllerGetSessionStatusAction = { }; /** - * Registers a Money Account wallet with MoonPay Iron. + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller reuses the Iron * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id from the authenticated MetaMask profile via KycService. + * it resolves the id via `GET /neobank/customers/{external_id}/external` + * (MetaMask canonical profile id). Customer resolution happens before the + * first list/lookup because list requires `customer_id` in the path. * Message construction, signing, submission, and ambiguous-write * reconciliation stay internal to KYC. * diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index db43d30b8a7..34dcb0edb92 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1801,6 +1801,11 @@ describe('KycController', () => { type: 'alreadyRegistered', registration, }); + expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); }); }); @@ -1828,6 +1833,10 @@ describe('KycController', () => { ).toMatchObject({ type: 'registered' }); expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'frame-customer', + address: '0xabc', + }); expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ data: expect.stringContaining('as customer frame-customer.'), from: '0xabc', @@ -1837,20 +1846,26 @@ describe('KycController', () => { address: '0xabc', customerId: 'frame-customer', signature: '0xsig', + idempotencyKey: expect.any(String), }), ); }, ); }); - it('falls back to resolving the customer id from the proxy', async () => { + it('falls back to resolving the customer id from the proxy before list', async () => { await withController(async ({ controller, handlers }) => { await controller.registerMoneyAccountWallet({ address: '0xabc' }); expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( expect.objectContaining({ customerId: 'iron-customer-fallback', + idempotencyKey: expect.any(String), }), ); }); diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 41da4dab8d5..b9dfc3b2b14 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -40,11 +40,14 @@ import { createInitialState, transition as transitionWalletRegistration, } from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; import type { RegistrationStatus, SelfHostedRegistration, } from './wallet-registration-service.js'; -import { WalletRegistrationError } from './wallet-registration-service.js'; // === GENERAL === @@ -1838,11 +1841,13 @@ export class KycController extends BaseController< } /** - * Registers a Money Account wallet with MoonPay Iron. + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller reuses the Iron * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id from the authenticated MetaMask profile via KycService. + * it resolves the id via `GET /neobank/customers/{external_id}/external` + * (MetaMask canonical profile id). Customer resolution happens before the + * first list/lookup because list requires `customer_id` in the path. * Message construction, signing, submission, and ambiguous-write * reconciliation stay internal to KYC. * @@ -1874,11 +1879,17 @@ export class KycController extends BaseController< return undefined; }; + // List requires customer_id in the neobank path, so resolve Iron's id + // before the first lookup. Prefer the ephemeral frame-captured value. + const customerId = + this.state.moonpayCustomerId ?? + (await this.messenger.call('KycService:getMoonpayCustomerId')); + const lookup = async (): Promise => { try { return await this.messenger.call( 'KycService:getWalletRegistrationStatus', - { address }, + { customerId, address }, ); } catch (error) { machine = transitionWalletRegistration(machine, { @@ -1910,9 +1921,10 @@ export class KycController extends BaseController< return existingResult; } - const customerId = - this.state.moonpayCustomerId ?? - (await this.messenger.call('KycService:getMoonpayCustomerId')); + // Stable across transient retries of the same ownership proof; refreshed + // when the UTC-dated message must be rebuilt and re-signed. + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; while (true) { const message = buildOwnershipMessage({ @@ -1920,6 +1932,11 @@ export class KycController extends BaseController< customerId, now: new Date(), }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + let signature: string; try { signature = await this.messenger.call( @@ -1943,6 +1960,7 @@ export class KycController extends BaseController< customerId, message, signature, + idempotencyKey, }, ); machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index eb0b35f3875..e5458df0d95 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -18,8 +18,8 @@ export type KycServiceGetGeoCountryAction = { }; /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. * * @returns Iron's internal customer id. */ @@ -29,9 +29,11 @@ export type KycServiceGetMoonpayCustomerIdAction = { }; /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. * @param params.address - Money Account address. * @returns Active, disabled, or absent registration status. */ diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 62fb93bfe10..efe4c4ad95d 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -60,38 +60,49 @@ describe('KycService', () => { }); describe('Money Account wallet registration', () => { - it('resolves the Iron customer id', async () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/customer') + .get('/neobank/customers/canonical-profile-1/external') .matchHeader('authorization', 'Bearer test-bearer') - .reply(200, { customerId: 'iron-customer-1' }); + .reply(200, { + id: 'iron-customer-1', + external_id: 'canonical-profile-1', + }); const { service } = getService(); expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); }); - it('checks Monad wallet registration status', async () => { + it('checks Monad wallet registration status for a customer', async () => { nock(MOCK_API_URL) - .get('/vendors/moonpay/self-hosted-wallets') + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) .reply(200, []); const { service } = getService(); expect( - await service.getWalletRegistrationStatus({ address: '0xabc' }), + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), ).toStrictEqual({ type: 'absent' }); }); - it('submits a signed Monad wallet ownership proof', async () => { + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { nock(MOCK_API_URL) - .post('/vendors/moonpay/self-hosted-wallets', { - customer_id: 'iron-customer-1', - address: '0xabc', - blockchain: 'Monad', - message: 'ownership message', - signature: '0xsig', - }) + .post( + '/neobank/addresses/crypto/selfhosted', + { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }, + { reqheaders: { 'idempotency-key': 'idem-1' } }, + ) .reply(200, { id: 'wallet-1', address: '0xabc', @@ -106,12 +117,32 @@ describe('KycService', () => { address: '0xabc', message: 'ownership message', signature: '0xsig', + idempotencyKey: 'idem-1', }), ).toMatchObject({ type: 'registered', registration: { id: 'wallet-1', blockchain: 'Monad' }, }); }); + + it('uses neobankBaseUrl when provided for wallet routes', async () => { + const neobankUrl = 'https://on-ramp.dev-api.cx.metamask.io'; + nock(neobankUrl) + .get('/neobank/customers/canonical-profile-1/external') + .reply(200, { id: 'iron-customer-1' }); + + const { service } = getService({ neobankBaseUrl: neobankUrl }); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('throws when the session profile has no usable external id', async () => { + const { service } = getService({ canonicalProfileId: '' }); + + await expect(service.getMoonpayCustomerId()).rejects.toThrow( + /Unable to resolve MetaMask canonical profile id/u, + ); + }); }); describe('fetchDisclaimers', () => { @@ -806,8 +837,11 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - Base URL of the KYC API. + * @param args.neobankBaseUrl - Optional on-ramp / neobank-proxy base URL. * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the * option so the service falls back to an empty string. + * @param args.canonicalProfileId - Canonical profile id returned by + * `AuthenticationController:getSessionProfile`. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -815,15 +849,19 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl = MOCK_API_URL, + neobankBaseUrl, // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, + canonicalProfileId = 'canonical-profile-1', }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; + neobankBaseUrl?: string; fractalEncryptionBaseUrl?: string | null; + canonicalProfileId?: string; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -839,6 +877,7 @@ function getService({ rootMessenger.delegate({ actions: [ 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', 'GeolocationController:getGeolocation', ], events: [], @@ -848,6 +887,15 @@ function getService({ 'AuthenticationController:getBearerToken', async () => bearerToken, ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => ({ + identifierId: 'id-1', + profileId: canonicalProfileId, + canonicalProfileId, + metaMetricsId: 'mm-1', + }), + ); rootMessenger.registerActionHandler( 'GeolocationController:getGeolocation', async () => geolocation as string, @@ -857,6 +905,7 @@ function getService({ fetch, messenger, baseUrl, + ...(neobankBaseUrl === undefined ? {} : { neobankBaseUrl }), ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 09686c098a5..624b88de456 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -8,7 +8,10 @@ import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; import { HttpError } from '@metamask/controller-utils'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { Messenger } from '@metamask/messenger'; -import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { + AuthenticationControllerGetBearerTokenAction, + AuthenticationControllerGetSessionProfileAction, +} from '@metamask/profile-sync-controller/auth'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -88,6 +91,7 @@ export type KycServiceActions = */ type AllowedActions = | AuthenticationControllerGetBearerTokenAction + | AuthenticationControllerGetSessionProfileAction | GeolocationControllerGetGeolocationAction; /** @@ -135,6 +139,13 @@ export type KycServiceOptions = { * Mandatory value that sets the base url to KYC api */ baseUrl: string; + /** + * Base URL of the on-ramp / neobank-proxy host used for Money Account wallet + * registration (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are + * under `/neobank`. When omitted, falls back to {@link baseUrl} so local + * tests can target a single mock host. + */ + neobankBaseUrl?: string; /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is @@ -311,11 +322,21 @@ export type GetSessionStatusParams = { sessionId: string; }; +export type GetWalletRegistrationStatusParams = { + customerId: string; + address: string; +}; + export type RegisterSelfHostedWalletParams = { customerId: string; address: string; message: string; signature: string; + /** + * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable + * key across retries of the same ownership body. + */ + idempotencyKey?: string; }; // === SERVICE DEFINITION === @@ -352,6 +373,8 @@ export class KycService extends BaseDataService< * @param options.messenger - The messenger suited for this service. * @param options.fetch - A function used to make HTTP requests. * @param options.baseUrl - Base URL of the KYC API + * @param options.neobankBaseUrl - Base URL of the neobank-proxy host for + * wallet registration. Defaults to `baseUrl` when omitted. * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. @@ -363,6 +386,7 @@ export class KycService extends BaseDataService< messenger, fetch: fetchFunction, baseUrl, + neobankBaseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, policyOptions = {}, @@ -381,8 +405,10 @@ export class KycService extends BaseDataService< this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; this.#walletRegistrationService = new WalletRegistrationService({ fetch: fetchFunction, - baseUrl, + baseUrl: neobankBaseUrl ?? baseUrl, getAuthToken: async (): Promise => this.#getBearerToken(), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), }); this.messenger.registerMethodActionHandlers( this, @@ -424,8 +450,8 @@ export class KycService extends BaseDataService< } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. * * @returns Iron's internal customer id. */ @@ -434,18 +460,20 @@ export class KycService extends BaseDataService< } /** - * Checks whether a Monad Money Account address is already registered. + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. * - * @param params - The address to check. + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. * @param params.address - Money Account address. * @returns Active, disabled, or absent registration status. */ async getWalletRegistrationStatus({ + customerId, address, - }: { - address: string; - }): Promise { + }: GetWalletRegistrationStatusParams): Promise { return await this.#walletRegistrationService.getRegistrationStatus({ + customerId, address, blockchain: 'Monad', }); @@ -918,6 +946,29 @@ export class KycService extends BaseDataService< return bearerToken; } + /** + * Resolves the MetaMask canonical profile id used as MoonPay's partner + * `external_id` for neobank customer lookup. + * + * @returns Canonical profile id. + */ + async #getCanonicalExternalId(): Promise { + const profile = await this.messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', + ); + } + return externalId; + } + /** * Performs a single JSON request. * diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 34ea0817c92..3e47d8abc87 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -52,6 +52,7 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, + GetWalletRegistrationStatusParams, RegisterSelfHostedWalletParams, SubmitConsentsParams, UkycSessionResponse, diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/kyc-controller/src/wallet-registration-service.test.ts index a8ee7540d23..c3be35aedeb 100644 --- a/packages/kyc-controller/src/wallet-registration-service.test.ts +++ b/packages/kyc-controller/src/wallet-registration-service.test.ts @@ -1,10 +1,14 @@ import { + createIdempotencyKey, + extractErrorBody, WalletRegistrationError, WalletRegistrationService, } from './wallet-registration-service.js'; -const BASE_URL = 'https://proxy.metamask.test'; +const BASE_URL = 'https://on-ramp.dev-api.cx.metamask.io'; const AUTH_TOKEN = 'session-jwt-abc'; +const EXTERNAL_ID = 'canonical-profile-1'; +const CUSTOMER_ID = '019ff69c-3039-77b0-9d5d-e4a3baefd7b7'; type FetchInit = { method?: string; @@ -33,7 +37,8 @@ const jsonResponse = (status: number, body: unknown): HttpResponse => ({ ok: status >= 200 && status < 300, status, json: async (): Promise => body, - text: async (): Promise => JSON.stringify(body), + text: async (): Promise => + typeof body === 'string' ? body : JSON.stringify(body), }); const textResponse = (status: number, body: string): HttpResponse => ({ @@ -57,6 +62,7 @@ const buildService = (fetchImpl: FetchLike): WalletRegistrationService => fetch: fetchImpl, baseUrl: BASE_URL, getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => EXTERNAL_ID, }); const verifiedAddress = ( @@ -76,11 +82,73 @@ const verifiedAddress = ( const EVM_ADDRESS = '0xAbC0000000000000000000000000000000000001'; +describe('createIdempotencyKey', () => { + it('returns a non-empty string', () => { + expect(createIdempotencyKey().length).toBeGreaterThan(0); + }); + + it('falls back when randomUUID is unavailable', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: undefined }, + }); + try { + expect(createIdempotencyKey()).toMatch(/^wallet-reg-/u); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } + } + }); +}); + +describe('extractErrorBody', () => { + it('returns whitespace-only bodies unchanged', () => { + expect(extractErrorBody(' ')).toBe(' '); + expect(extractErrorBody('')).toBe(''); + }); + + it('unwraps a JSON-encoded string', () => { + expect(extractErrorBody(JSON.stringify('already exists'))).toBe( + 'already exists', + ); + }); + + it('prefers message on a JSON object', () => { + expect(extractErrorBody(JSON.stringify({ message: 'forbidden' }))).toBe( + 'forbidden', + ); + }); + + it('keeps a JSON object without message as raw text', () => { + const raw = JSON.stringify({ code: 'x', detail: 'nope' }); + expect(extractErrorBody(raw)).toBe(raw); + }); + + it('returns plain text that is not JSON', () => { + expect(extractErrorBody('not json at all')).toBe('not json at all'); + }); + + it('returns non-object JSON values as the raw trimmed text', () => { + expect(extractErrorBody('null')).toBe('null'); + expect(extractErrorBody('42')).toBe('42'); + expect(extractErrorBody('true')).toBe('true'); + }); +}); + describe('WalletRegistrationService.getMoonpayCustomerId', () => { - it('returns the Iron customer id from the authenticated proxy lookup', async () => { + it('returns Iron customer id from GET /neobank/customers/{external_id}/external', async () => { const fetchMock = jest.fn( async (): Promise => - jsonResponse(200, { customerId: 'iron-customer-1' }), + jsonResponse(200, { + id: 'iron-customer-1', + external_id: EXTERNAL_ID, + status: 'Active', + }), ); expect(await buildService(fetchMock).getMoonpayCustomerId()).toBe( @@ -88,7 +156,7 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); expect(fetchMock).toHaveBeenCalledWith( - `${BASE_URL}/vendors/moonpay/customer`, + `${BASE_URL}/neobank/customers/${EXTERNAL_ID}/external`, expect.objectContaining({ method: 'GET', headers: expect.objectContaining({ @@ -98,15 +166,18 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ); }); - it('maps a failed customer lookup to a typed HTTP error', async () => { + it('maps a failed customer lookup to a typed HTTP error with transparent body', async () => { const fetchMock = jest.fn( - async (): Promise => - jsonResponse(404, { code: 'iron_error', message: 'not found' }), + async (): Promise => textResponse(404, 'not found'), ); await expect( buildService(fetchMock).getMoonpayCustomerId(), - ).rejects.toMatchObject({ kind: 'notFound', httpStatus: 404 }); + ).rejects.toMatchObject({ + kind: 'notFound', + httpStatus: 404, + body: 'not found', + }); }); it('rejects malformed customer lookup responses', async () => { @@ -122,35 +193,59 @@ describe('WalletRegistrationService.getMoonpayCustomerId', () => { ).getMoonpayCustomerId(), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); + + it('rejects an empty external id before calling the network', async () => { + const fetchMock = jest.fn(); + const service = new WalletRegistrationService({ + fetch: fetchMock, + baseUrl: BASE_URL, + getAuthToken: async (): Promise => AUTH_TOKEN, + getExternalId: async (): Promise => '', + }); + + await expect(service.getMoonpayCustomerId()).rejects.toMatchObject({ + kind: 'malformedResponse', + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); }); describe('WalletRegistrationService.getRegistrationStatus', () => { - it('calls the MetaMask proxy list endpoint (not Iron) with the session token', async () => { + it('lists via /neobank/addresses/crypto/{customer_id}?filter=SelfHosted', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, []), ); const service = buildService(fetchMock); await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe( + `${BASE_URL}/neobank/addresses/crypto/${CUSTOMER_ID}?filter=SelfHosted`, + ); expect(url).not.toContain('iron.xyz'); + expect(url).not.toContain('/vendors/moonpay/'); expect(init.method).toBe('GET'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); }); - it('returns an active match parsed from wallet_address', async () => { + it('returns an active match parsed from wallet_address (Monad filter client-side)', async () => { const fetchMock = jest.fn( - async (): Promise => jsonResponse(200, [verifiedAddress()]), + async (): Promise => + jsonResponse(200, [ + verifiedAddress({ blockchain: 'Ethereum' }), + verifiedAddress(), + ]), ); const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: '0xabc0000000000000000000000000000000000001', blockchain: 'Monad', }); @@ -169,6 +264,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -184,6 +280,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -202,6 +299,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { const service = buildService(fetchMock); const status = await service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }); @@ -217,6 +315,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -231,10 +330,11 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), - ).rejects.toMatchObject({ kind: 'lookupUnavailable' }); + ).rejects.toMatchObject({ kind: 'lookupUnavailable', body: 'boom' }); }); it('throws a lookupUnavailable error when the list body is malformed', async () => { @@ -245,6 +345,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -259,6 +360,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -273,6 +375,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { await expect( service.getRegistrationStatus({ + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad', }), @@ -281,7 +384,7 @@ describe('WalletRegistrationService.getRegistrationStatus', () => { }); const registerRequest = { - customerId: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customerId: CUSTOMER_ID, address: EVM_ADDRESS, blockchain: 'Monad' as const, message: 'I am verifying ownership ...', @@ -293,35 +396,33 @@ const selfHostedResponse = ( ): Record => ({ id: 'wallet-1', address: EVM_ADDRESS, - customer_id: '019ff69c-3039-77b0-9d5d-e4a3baefd7b7', + customer_id: CUSTOMER_ID, disabled: false, signature: '0xdeadbeef', created_at: '2026-08-12T10:00:00Z', ...overrides, }); -const errorEnvelope = (status: number, message: string): HttpResponse => - jsonResponse(status, { - code: 'iron_error', - message, - }); - describe('WalletRegistrationService.registerSelfHostedWallet', () => { - it('sends the five contract fields via POST and returns registered on 200', async () => { + it('posts to /neobank/addresses/crypto/selfhosted with an idempotency key', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), ); const service = buildService(fetchMock); - const outcome = await service.registerSelfHostedWallet(registerRequest); + const outcome = await service.registerSelfHostedWallet({ + ...registerRequest, + idempotencyKey: 'idem-wallet-1', + }); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - expect(url).toBe(`${BASE_URL}/vendors/moonpay/self-hosted-wallets`); + expect(url).toBe(`${BASE_URL}/neobank/addresses/crypto/selfhosted`); expect(url).not.toContain('iron.xyz'); expect(init.method).toBe('POST'); expect(init.headers.authorization).toBe(`Bearer ${AUTH_TOKEN}`); + expect(init.headers['Idempotency-Key']).toBe('idem-wallet-1'); expect(JSON.parse(init.body ?? '{}')).toStrictEqual({ customer_id: registerRequest.customerId, address: registerRequest.address, @@ -336,7 +437,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { }); }); - it('does not send an idempotency key (the backend derives it)', async () => { + it('generates an Idempotency-Key when the caller omits one', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(200, selfHostedResponse()), @@ -346,17 +447,13 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await service.registerSelfHostedWallet(registerRequest); const [, init] = fetchMock.mock.calls[0] as [string, FetchInit]; - const headerKeys = Object.keys(init.headers).map((key) => - key.toLowerCase(), - ); - expect(headerKeys).not.toContain('idempotency-key'); - expect(JSON.parse(init.body ?? '{}')).not.toHaveProperty('idempotencyKey'); + expect(init.headers['Idempotency-Key']?.length).toBeGreaterThan(0); }); - it('maps any 409 to an ambiguous conflict error carrying the body', async () => { + it('maps a plain-string 409 body to an ambiguous conflict error', async () => { const fetchMock = jest.fn( async (): Promise => - errorEnvelope( + textResponse( 409, 'A crypto address with this wallet address already exists', ), @@ -374,7 +471,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 5xx to a transient error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(500, 'internal error'), + async (): Promise => textResponse(500, 'internal error'), ); const service = buildService(fetchMock); @@ -407,7 +504,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 400 to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(400, 'bad message'), + async (): Promise => textResponse(400, 'bad message'), ); const service = buildService(fetchMock); @@ -418,7 +515,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps an unmapped 4xx (422) to a validation error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(422, 'unprocessable'), + async (): Promise => textResponse(422, 'unprocessable'), ); const service = buildService(fetchMock); @@ -429,7 +526,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 401 to unauthorized', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(401, 'session expired'), + async (): Promise => textResponse(401, 'session expired'), ); await expect( @@ -439,10 +536,10 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { it('maps 403 to forbidden and 404 to notFound', async () => { const forbiddenFetch = jest.fn( - async (): Promise => errorEnvelope(403, 'suspended'), + async (): Promise => textResponse(403, 'suspended'), ); const notFoundFetch = jest.fn( - async (): Promise => errorEnvelope(404, 'not found'), + async (): Promise => textResponse(404, 'not found'), ); const forbidden = await buildService(forbiddenFetch) @@ -460,7 +557,7 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { expect(notFound).toMatchObject({ kind: 'notFound' }); }); - it('maps an error envelope without a code', async () => { + it('maps a JSON error object with message when present', async () => { const fetchMock = jest.fn( async (): Promise => jsonResponse(403, { message: 'forbidden' }), @@ -469,12 +566,80 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { await expect( service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'forbidden' }); + ).rejects.toMatchObject({ kind: 'forbidden', body: 'forbidden' }); + }); + + it('maps a JSON-encoded string error body', async () => { + const fetchMock = jest.fn( + async (): Promise => + textResponse(409, JSON.stringify('already exists')), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'conflict', body: 'already exists' }); + }); + + it('keeps a JSON object without message as the raw body', async () => { + const fetchMock = jest.fn( + async (): Promise => + jsonResponse(400, { code: 'x', detail: 'nope' }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: JSON.stringify({ code: 'x', detail: 'nope' }), + }); + }); + + it('keeps a whitespace-only error body as-is', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ' '), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'validation', body: ' ' }); + }); + + it('omits Error.message when the upstream body is empty', async () => { + const fetchMock = jest.fn( + async (): Promise => textResponse(400, ''), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ + kind: 'validation', + body: '', + message: 'wallet registration failed: validation', + }); + }); + + it('maps an unreadable error body to malformedResponse', async () => { + const fetchMock = jest.fn( + async (): Promise => ({ + ok: false, + status: 500, + json: async (): Promise => { + throw new Error('no json'); + }, + text: async (): Promise => { + throw new Error('no text'); + }, + }), + ); + + await expect( + buildService(fetchMock).registerSelfHostedWallet(registerRequest), + ).rejects.toMatchObject({ kind: 'malformedResponse', httpStatus: 500 }); }); it('maps 429 to a rateLimited error', async () => { const fetchMock = jest.fn( - async (): Promise => errorEnvelope(429, 'slow down'), + async (): Promise => textResponse(429, 'slow down'), ); const service = buildService(fetchMock); @@ -516,15 +681,4 @@ describe('WalletRegistrationService.registerSelfHostedWallet', () => { service.registerSelfHostedWallet(registerRequest), ).rejects.toMatchObject({ kind: 'malformedResponse' }); }); - - it('maps a non-JSON error body to malformedResponse', async () => { - const fetchMock = jest.fn( - async (): Promise => invalidJsonResponse(400), - ); - const service = buildService(fetchMock); - - await expect( - service.registerSelfHostedWallet(registerRequest), - ).rejects.toMatchObject({ kind: 'malformedResponse' }); - }); }); diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/kyc-controller/src/wallet-registration-service.ts index 08d118d3b8c..240f6720474 100644 --- a/packages/kyc-controller/src/wallet-registration-service.ts +++ b/packages/kyc-controller/src/wallet-registration-service.ts @@ -75,11 +75,21 @@ export class WalletRegistrationError extends Error { export type WalletRegistrationServiceOptions = { fetch: FetchLike; + /** + * Base URL of the Money Movement neobank-proxy host + * (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are under `/neobank`. + */ baseUrl: string; getAuthToken: () => Promise; + /** + * MetaMask profile / partner external id used as MoonPay `external_id` + * (typically `AuthenticationController:getSessionProfile().canonicalProfileId`). + */ + getExternalId: () => Promise; }; export type GetRegistrationStatusRequest = { + customerId: string; address: string; blockchain: Blockchain; }; @@ -90,6 +100,11 @@ export type RegisterSelfHostedWalletRequest = { blockchain: Blockchain; message: string; signature: string; + /** + * Stable key reused across retries of the same ownership proof. Generated + * when omitted. + */ + idempotencyKey?: string; }; /** Successful registration outcome. */ @@ -98,9 +113,6 @@ export type RegistrationOutcome = { registration: SelfHostedRegistration; }; -const SELF_HOSTED_PATH = '/vendors/moonpay/self-hosted-wallets'; -const MOONPAY_CUSTOMER_PATH = '/vendors/moonpay/customer'; - /** * Normalizes a Monad EVM address for case-insensitive comparison. * @@ -137,7 +149,53 @@ function mapStatusToKind(status: number): WalletRegistrationErrorKind { } /** - * Data service that talks to the MetaMask backend proxy for MoonPay Iron + * Builds a client-side Idempotency-Key for MoonPay POSTs. Prefer a stable + * caller-supplied key across retries of the same proof. + * + * @returns A random UUID when available, otherwise a timestamped fallback. + */ +export function createIdempotencyKey(): string { + const cryptoObj = globalThis.crypto as + | { randomUUID?: () => string } + | undefined; + if (typeof cryptoObj?.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `wallet-reg-${Date.now()}-${Math.random().toString(16).slice(2)}`; +} + +/** + * Extracts a human-readable error body from a transparent neobank-proxy + * response. Upstream may return a plain string or a JSON value; both are + * mirrored 1:1 (no `{ code: 'iron_error' }` envelope). + * + * @param raw - Raw response text. + * @returns Normalized body string for {@link WalletRegistrationError}. + */ +export function extractErrorBody(raw: string): string { + const trimmed = raw.trim(); + if (!trimmed) { + return raw; + } + try { + const parsed: unknown = JSON.parse(trimmed); + if (typeof parsed === 'string') { + return parsed; + } + if (parsed && typeof parsed === 'object') { + const { message } = parsed as { message?: unknown }; + if (typeof message === 'string') { + return message; + } + } + return trimmed; + } catch { + return trimmed; + } +} + +/** + * Data service that talks to the Money Movement neobank-proxy for MoonPay Iron * self-hosted wallet registration. It never calls Iron directly, so the Iron * API key never ships in the client. */ @@ -148,23 +206,36 @@ export class WalletRegistrationService { readonly #getAuthToken: () => Promise; + readonly #getExternalId: () => Promise; + constructor(options: WalletRegistrationServiceOptions) { this.#fetch = options.fetch; this.#baseUrl = options.baseUrl.replace(/\/$/u, ''); this.#getAuthToken = options.getAuthToken; + this.#getExternalId = options.getExternalId; } /** - * Resolves Iron's internal customer id from the authenticated MetaMask - * profile. Used when the current KYC flow has not already received - * `customer.id` from MoonPay's hosted frame. + * Resolves Iron's internal customer id via + * `GET /neobank/customers/{external_id}/external`, using the MetaMask + * profile/canonical id as `external_id`. Used when the current KYC flow has + * not already received `customer.id` from MoonPay's hosted frame. * * @returns Iron's internal customer id. */ async getMoonpayCustomerId(): Promise { - const token = await this.#getAuthToken(); + const [token, externalId] = await Promise.all([ + this.#getAuthToken(), + this.#getExternalId(), + ]); + if (!externalId) { + throw new WalletRegistrationError('malformedResponse', { + message: 'MetaMask external id (canonical profile id) is empty', + }); + } + const response = await this.#fetch( - `${this.#baseUrl}${MOONPAY_CUSTOMER_PATH}`, + `${this.#baseUrl}/neobank/customers/${encodeURIComponent(externalId)}/external`, { method: 'GET', headers: { @@ -187,32 +258,38 @@ export class WalletRegistrationService { }); } - const { customerId } = payload as { customerId?: unknown }; - if (typeof customerId !== 'string' || customerId.length === 0) { + const { id } = payload as { id?: unknown }; + if (typeof id !== 'string' || id.length === 0) { throw new WalletRegistrationError('malformedResponse', { - message: 'MoonPay customer body missing customerId', + message: 'MoonPay customer body missing id', }); } - return customerId; + return id; } /** - * Reconciles a wallet against the customer's registered self-hosted addresses. - * A failed or malformed lookup is reported as `lookupUnavailable` and never - * downgraded to `absent`. + * Reconciles a wallet against the customer's registered self-hosted addresses + * via `GET /neobank/addresses/crypto/{customer_id}?filter=SelfHosted`. + * Upstream returns all self-hosted chains; Monad filtering stays client-side + * for the POC. A failed or malformed lookup is reported as + * `lookupUnavailable` and never downgraded to `absent`. * - * @param request - Monad address to reconcile. + * @param request - Customer id and Monad address to reconcile. * @returns The active / disabled / absent status for the address. */ async getRegistrationStatus( request: GetRegistrationStatusRequest, ): Promise { - const { address, blockchain } = request; + const { customerId, address, blockchain } = request; let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { + const url = new URL( + `${this.#baseUrl}/neobank/addresses/crypto/${encodeURIComponent(customerId)}`, + ); + url.searchParams.set('filter', 'SelfHosted'); + response = await this.#fetch(url.toString(), { method: 'GET', headers: { accept: 'application/json', @@ -230,7 +307,7 @@ export class WalletRegistrationService { const body = await response.text(); throw new WalletRegistrationError('lookupUnavailable', { httpStatus: response.status, - body, + body: extractErrorBody(body), }); } @@ -272,11 +349,12 @@ export class WalletRegistrationService { } /** - * Registers a self-hosted wallet through the MetaMask proxy. The proxy - * resolves the customer, derives the idempotency key, and attaches the API - * version, so the client never manages those. Every non-2xx response is - * mapped to a typed error; `409` is deliberately surfaced as an ambiguous - * `conflict` that the caller must reconcile with a follow-up status lookup. + * Registers a self-hosted wallet through neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. The client supplies + * `customer_id` and an `Idempotency-Key` (generated when omitted). Every + * non-2xx response is mapped to a typed error; `409` is deliberately + * surfaced as an ambiguous `conflict` that the caller must reconcile with a + * follow-up status lookup. * * @param request - Customer id, address, blockchain, message, and signature. * @returns The registered outcome on success. @@ -284,24 +362,29 @@ export class WalletRegistrationService { async registerSelfHostedWallet( request: RegisterSelfHostedWalletRequest, ): Promise { + const idempotencyKey = request.idempotencyKey ?? createIdempotencyKey(); let response: HttpResponse; try { const token = await this.#getAuthToken(); - response = await this.#fetch(`${this.#baseUrl}${SELF_HOSTED_PATH}`, { - method: 'POST', - headers: { - accept: 'application/json', - 'content-type': 'application/json', - authorization: `Bearer ${token}`, + response = await this.#fetch( + `${this.#baseUrl}/neobank/addresses/crypto/selfhosted`, + { + method: 'POST', + headers: { + accept: 'application/json', + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + 'Idempotency-Key': idempotencyKey, + }, + body: JSON.stringify({ + customer_id: request.customerId, + address: request.address, + blockchain: request.blockchain, + message: request.message, + signature: request.signature, + }), }, - body: JSON.stringify({ - customer_id: request.customerId, - address: request.address, - blockchain: request.blockchain, - message: request.message, - signature: request.signature, - }), - }); + ); } catch (error) { throw new WalletRegistrationError('transient', { message: 'self-hosted registration request failed', @@ -342,21 +425,23 @@ export class WalletRegistrationService { } async #toHttpError(response: HttpResponse): Promise { - let envelope: { message?: string }; + let raw = ''; try { - envelope = (await response.json()) as { message?: string }; + raw = await response.text(); } catch { return new WalletRegistrationError('malformedResponse', { httpStatus: response.status, - message: 'error body was not valid JSON', + message: 'error body could not be read', }); } const { status } = response; const kind = mapStatusToKind(status); + const body = extractErrorBody(raw); return new WalletRegistrationError(kind, { httpStatus: status, - body: envelope.message, + body, + message: body || undefined, }); } From fee45dd316d0820292f9e727369c0d6d18ea12e5 Mon Sep 17 00:00:00 2001 From: George Weiler Date: Wed, 12 Aug 2026 22:39:25 -0600 Subject: [PATCH 62/67] feat(ramps): fall back to Profile Sync for autoramp customer id Prefer KycController identity when present, otherwise map the Profile Sync id through NeoBankService.getCustomerByExternalId so autoramp creation works before KYC has cached a MoonPay customer id. Co-authored-by: Cursor --- .../ramps-controller/src/RampsController.ts | 70 +++++++++++++++---- 1 file changed, 57 insertions(+), 13 deletions(-) diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index ba15cd39d02..f8fe570ee35 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -36,6 +36,7 @@ import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/inde import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, + NeoBankServiceGetCustomerByExternalIdAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; @@ -194,6 +195,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'TransakService:getActiveOrders', 'NeoBankService:getAutoramp', 'NeoBankService:createAutoramp', + 'NeoBankService:getCustomerByExternalId', ] as const satisfies readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] @@ -207,6 +209,7 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'KycController:getCustomerIdentity', + 'AuthenticationController:getSessionProfile', ] as const; /** @@ -702,11 +705,13 @@ type AllowedActions = | TransakServiceGetActiveOrdersAction | NeoBankServiceGetAutorampAction | NeoBankServiceCreateAutorampAction + | NeoBankServiceGetCustomerByExternalIdAction | KycControllerGetCustomerIdentityAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction - | AuthenticationController.AuthenticationControllerIsSignedInAction; + | AuthenticationController.AuthenticationControllerIsSignedInAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; /** * Published when the state of {@link RampsController} changes. @@ -2653,11 +2658,10 @@ export class RampsController extends BaseController< * Creates an autoramp via the Ramp API neo-bank proxy and applies the * returned snapshot locally. * - * The MoonPay `customer_id` is not accepted from callers: it is resolved from - * the KYC controller's session-scoped identity and injected into the request. - * This keeps the sensitive customer id owned by the KYC controller and avoids - * requiring the UI to know or plumb it. Throws when no verified identity is - * available yet. + * The MoonPay `customer_id` is not accepted from callers: it is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request. This keeps the sensitive customer id owned by KYC / the neo-bank + * proxy and avoids requiring the UI to know or plumb it. * * @param request - CreateAutoramp payload (any `customer_id` is overwritten). * @param options - Optional idempotency key forwarded to the proxy. @@ -2668,14 +2672,9 @@ export class RampsController extends BaseController< request: CreateAutorampRequest, options: { idempotencyKey?: string } = {}, ): Promise { - const identity = this.messenger.call('KycController:getCustomerIdentity'); - if (!identity) { - throw new Error( - 'Cannot create autoramp: no verified KYC customer identity is available.', - ); - } + const customerId = await this.resolveAutorampCustomerId(); - const body = { ...request, customer_id: identity.id }; + const body = { ...request, customer_id: customerId }; const remote = await this.messenger.call( 'NeoBankService:createAutoramp', body, @@ -2684,6 +2683,51 @@ export class RampsController extends BaseController< return this.#applyAutorampRemoteSnapshot(remote); } + /** + * Resolves the MoonPay `customer_id` for autoramp operations. + * + * Prefers the KYC controller's session-scoped identity (populated when a + * MoonPay Check/Auth frame reports a `customer.id`). When that is not yet + * available, falls back to mapping the wallet's Profile Sync id (the partner + * `external_id`) to the MoonPay customer via the neo-bank proxy's + * `GET /neobank/customers/{external_id}/external`. + * + * @returns The MoonPay customer id. + */ + async resolveAutorampCustomerId(): Promise { + const identity = this.messenger.call('KycController:getCustomerIdentity'); + if (identity?.id) { + return identity.id; + } + + const profile = await this.messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const externalId = profile?.profileId; + if (!externalId) { + throw new Error( + 'Cannot create autoramp: wallet is not signed in to Profile Sync.', + ); + } + + const customer = await this.messenger.call( + 'NeoBankService:getCustomerByExternalId', + externalId, + ); + const customerId = + customer && + typeof customer === 'object' && + typeof (customer as { id?: unknown }).id === 'string' + ? (customer as { id: string }).id + : null; + if (!customerId) { + throw new Error( + `Cannot create autoramp: no MoonPay customer is mapped to external id "${externalId}".`, + ); + } + return customerId; + } + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. From 0a3674bc4783ebb277f96a41141998524f03909d Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 01:03:18 -0600 Subject: [PATCH 63/67] refactor(ramps): move Money Account wallet signing from kyc-controller to ramps-controller (#9858) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Money Account self-hosted wallet registration (proving wallet ownership to MoonPay Iron via the neobank-proxy) lived in `@metamask/kyc-controller`. This PR moves it to `@metamask/ramps-controller`, which is the correct Money Movement owner, and removes the ramps→kyc package dependency that broke the monorepo TypeScript project-reference build. - Moved `wallet-registration-service.ts`, `wallet-registration-machine.ts`, `ownership-message.ts` (and tests) from `packages/kyc-controller/src` to `packages/ramps-controller/src`. - Moved `registerMoneyAccountWallet` from `KycController` to `RampsController` (resolves customer id via `resolveAutorampCustomerId`, signs via `KeyringController:signPersonalMessage`, submits via `NeoBankService`). - Moved the three wallet HTTP methods from `KycService` to `NeoBankService` (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), built lazily so an invalid environment only throws at request time. Removed the `neobankBaseUrl` KycService option. - Removed the `@metamask/kyc-controller` dependency (package.json + both tsconfigs) from `ramps-controller`, and the unused `@metamask/keyring-controller` dependency from `kyc-controller`. `RampsController` now declares local structural types for the two type-only messenger actions it needs, so the packages are decoupled in the build graph. - Regenerated messenger action types for both packages, updated both `index.ts` export surfaces, moved/ported wallet-registration tests into ramps, and updated both CHANGELOGs. ## Why 1. **Architectural ownership** — wallet ownership signing is a Money Movement / neobank-proxy concern, not KYC identity. 2. **Unblocks monorepo build / preview publish** — the ramps→kyc package dependency caused TS6059 / TS6307 with project references and blocked `@metamaskbot publish-preview` on core PR #9853. ## Test plan - [x] `yarn build` (full monorepo, with project references) — exit 0; kyc-controller and ramps-controller build cleanly - [x] `yarn workspace @metamask/kyc-controller test` — passing, 100% coverage - [x] `yarn workspace @metamask/ramps-controller test:verbose` — 842/842 tests, 18/18 suites (coverage thresholds for pre-existing untested areas like `autoramp-syncing/controller-integration.ts` are pre-existing on `neobank-demo`, not introduced here) - [x] `yarn workspace @metamask/kyc-controller messenger-action-types:check` — up to date - [x] `yarn workspace @metamask/ramps-controller messenger-action-types:check` — up to date - [x] `yarn eslint packages/kyc-controller/src` — clean - [ ] Confirm CI build / typecheck / tests are green on this PR before merge Made with [Cursor](https://cursor.com) Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 8 +- packages/kyc-controller/package.json | 1 - .../src/KycController-method-action-types.ts | 21 -- .../kyc-controller/src/KycController.test.ts | 238 --------------- packages/kyc-controller/src/KycController.ts | 206 +------------ .../src/KycService-method-action-types.ts | 39 --- .../kyc-controller/src/KycService.test.ts | 104 ------- packages/kyc-controller/src/KycService.ts | 118 +------- packages/kyc-controller/src/index.test.ts | 1 - packages/kyc-controller/src/index.ts | 13 - packages/kyc-controller/tsconfig.build.json | 1 - packages/kyc-controller/tsconfig.json | 1 - packages/ramps-controller/CHANGELOG.md | 5 +- packages/ramps-controller/package.json | 1 - .../src/NeoBankService-method-action-types.ts | 42 ++- .../src/NeoBankService.test.ts | 113 ++++++- .../ramps-controller/src/NeoBankService.ts | 118 +++++++- .../RampsController-method-action-types.ts | 29 +- .../src/RampsController.test.ts | 279 +++++++++++++++++- .../ramps-controller/src/RampsController.ts | 236 ++++++++++++++- packages/ramps-controller/src/index.ts | 19 ++ .../src/ownership-message.test.ts | 0 .../src/ownership-message.ts | 0 .../src/wallet-registration-machine.test.ts | 0 .../src/wallet-registration-machine.ts | 0 .../src/wallet-registration-service.test.ts | 0 .../src/wallet-registration-service.ts | 0 packages/ramps-controller/tsconfig.build.json | 3 - packages/ramps-controller/tsconfig.json | 3 - yarn.lock | 4 +- 30 files changed, 834 insertions(+), 769 deletions(-) rename packages/{kyc-controller => ramps-controller}/src/ownership-message.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/ownership-message.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-machine.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-machine.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-service.test.ts (100%) rename packages/{kyc-controller => ramps-controller}/src/wallet-registration-service.ts (100%) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index ec6129b1a94..c661ce10e1b 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -11,10 +11,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. - Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852)) -- `KycController:registerMoneyAccountWallet`, an address-only action that resolves the MoonPay customer, signs a Monad Money Account ownership message, and registers the wallet through the MetaMask neobank-proxy ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) -- Internal wallet registration service and state machine support for `409` disambiguation, transient-failure reconciliation, UTC date rollover, and typed failures ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) - - Targets transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`), client-side Monad filtering, `Idempotency-Key`, and upstream error bodies mirrored 1:1. - - Optional `neobankBaseUrl` on `KycService` so KYC and wallet registration can use different hosts. - Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) - Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. @@ -25,4 +21,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add UKYC session-status polling to `KycController` - Add handling in `KycController.startSumSub` for applicants already being processed by the vendor +### Removed + +- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/package.json b/packages/kyc-controller/package.json index 22967e56612..5b0c23f7fff 100644 --- a/packages/kyc-controller/package.json +++ b/packages/kyc-controller/package.json @@ -61,7 +61,6 @@ "@metamask/base-data-service": "^0.1.3", "@metamask/controller-utils": "^12.3.0", "@metamask/geolocation-controller": "^1.0.0", - "@metamask/keyring-controller": "^27.1.1", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/superstruct": "^3.4.1", diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 4a837f24527..b2aa85cca42 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -215,26 +215,6 @@ export type KycControllerGetSessionStatusAction = { handler: KycController['getSessionStatus']; }; -/** - * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. - * - * Consumers provide only the Monad address. The controller reuses the Iron - * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id via `GET /neobank/customers/{external_id}/external` - * (MetaMask canonical profile id). Customer resolution happens before the - * first list/lookup because list requires `customer_id` in the path. - * Message construction, signing, submission, and ambiguous-write - * reconciliation stay internal to KYC. - * - * @param params - Money Account wallet registration parameters. - * @param params.address - Monad Money Account address. - * @returns The successful registration state. - */ -export type KycControllerRegisterMoneyAccountWalletAction = { - type: `KycController:registerMoneyAccountWallet`; - handler: KycController['registerMoneyAccountWallet']; -}; - /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. @@ -263,5 +243,4 @@ export type KycControllerMethodActions = | KycControllerStartSumSubAction | KycControllerRefreshKycStatusAction | KycControllerGetSessionStatusAction - | KycControllerRegisterMoneyAccountWalletAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 34dcb0edb92..aef1d4f36e9 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -15,7 +15,6 @@ import type { KycControllerMessenger } from './KycController.js'; import type { KycSumSubLauncher } from './types.js'; import { verifyJwtChain } from './ukyc/jwtChain.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; -import { WalletRegistrationError } from './wallet-registration-service.js'; // `verifyJwtChain` (JWKS attestation) and `wrapEncryptionKey` (X25519 sealing) // need a real signed chain / valid keys, so they are stubbed here; the rest of @@ -1779,204 +1778,6 @@ describe('KycController', () => { }); }); - describe('registerMoneyAccountWallet', () => { - const registration = { - id: 'wallet-1', - address: '0xabc', - blockchain: 'Monad' as const, - disabled: false, - isSelf: true, - }; - - it('returns an existing active registration without signing', async () => { - await withController(async ({ controller, handlers }) => { - handlers.getWalletRegistrationStatus.mockResolvedValue({ - type: 'active', - registration, - }); - - expect( - await controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).toStrictEqual({ - type: 'alreadyRegistered', - registration, - }); - expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); - expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ - customerId: 'iron-customer-fallback', - address: '0xabc', - }); - expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); - }); - }); - - it('returns an existing disabled registration without signing', async () => { - await withController(async ({ controller, handlers }) => { - handlers.getWalletRegistrationStatus.mockResolvedValue({ - type: 'disabled', - registration: { ...registration, disabled: true }, - }); - - expect( - await controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).toMatchObject({ type: 'registeredDisabled' }); - expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); - }); - }); - - it('prefers the customer id captured from the MoonPay frame', async () => { - await withController( - { options: { state: { moonpayCustomerId: 'frame-customer' } } }, - async ({ controller, handlers }) => { - expect( - await controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).toMatchObject({ type: 'registered' }); - - expect(handlers.getMoonpayCustomerId).not.toHaveBeenCalled(); - expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ - customerId: 'frame-customer', - address: '0xabc', - }); - expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ - data: expect.stringContaining('as customer frame-customer.'), - from: '0xabc', - }); - expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( - expect.objectContaining({ - address: '0xabc', - customerId: 'frame-customer', - signature: '0xsig', - idempotencyKey: expect.any(String), - }), - ); - }, - ); - }); - - it('falls back to resolving the customer id from the proxy before list', async () => { - await withController(async ({ controller, handlers }) => { - await controller.registerMoneyAccountWallet({ address: '0xabc' }); - - expect(handlers.getMoonpayCustomerId).toHaveBeenCalledTimes(1); - expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ - customerId: 'iron-customer-fallback', - address: '0xabc', - }); - expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( - expect.objectContaining({ - customerId: 'iron-customer-fallback', - idempotencyKey: expect.any(String), - }), - ); - }); - }); - - it('reconciles an ambiguous conflict as already registered', async () => { - await withController(async ({ controller, handlers }) => { - handlers.getWalletRegistrationStatus - .mockResolvedValueOnce({ type: 'absent' }) - .mockResolvedValueOnce({ type: 'active', registration }); - handlers.registerSelfHostedWallet.mockRejectedValue( - new WalletRegistrationError('conflict', { httpStatus: 409 }), - ); - - expect( - await controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).toStrictEqual({ - type: 'alreadyRegistered', - registration, - }); - }); - }); - - it('rethrows a transient failure when reconciliation remains absent', async () => { - await withController(async ({ controller, handlers }) => { - const error = new WalletRegistrationError('transient', { - httpStatus: 502, - }); - handlers.registerSelfHostedWallet.mockRejectedValue(error); - - await expect( - controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).rejects.toBe(error); - expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); - expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); - }); - }); - - it('rebuilds and re-signs after a UTC date rollover', async () => { - jest.useFakeTimers(); - jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); - try { - await withController(async ({ controller, handlers }) => { - handlers.registerSelfHostedWallet - .mockImplementationOnce(async () => { - jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); - throw new WalletRegistrationError('validation', { - httpStatus: 400, - }); - }) - .mockResolvedValueOnce({ - type: 'registered', - registration, - }); - - await controller.registerMoneyAccountWallet({ address: '0xabc' }); - - expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); - expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( - 'signed on 12/08/2026', - ); - expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( - 'signed on 13/08/2026', - ); - }); - } finally { - jest.useRealTimers(); - } - }); - - it.each([ - new WalletRegistrationError('validation', { httpStatus: 400 }), - new WalletRegistrationError('rateLimited', { httpStatus: 429 }), - new WalletRegistrationError('unauthorized', { httpStatus: 401 }), - new Error('unexpected'), - ])('rethrows terminal registration failure %#', async (error) => { - await withController(async ({ controller, handlers }) => { - handlers.registerSelfHostedWallet.mockRejectedValue(error); - - await expect( - controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).rejects.toBe(error); - expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); - }); - }); - - it('rethrows an initial lookup failure without signing', async () => { - await withController(async ({ controller, handlers }) => { - const error = new Error('lookup failed'); - handlers.getWalletRegistrationStatus.mockRejectedValue(error); - - await expect( - controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).rejects.toBe(error); - expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); - }); - }); - - it('rethrows a signing failure without submitting', async () => { - await withController(async ({ controller, handlers }) => { - const error = new Error('signing failed'); - handlers.signPersonalMessage.mockRejectedValue(error); - - await expect( - controller.registerMoneyAccountWallet({ address: '0xabc' }), - ).rejects.toBe(error); - expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); - }); - }); - }); - describe('reset', () => { it('clears session state but preserves persisted terms', async () => { await withController( @@ -2699,10 +2500,6 @@ type ServiceHandlers = { createUkycSession: jest.Mock; createJourney: jest.Mock; getSessionStatus: jest.Mock; - getMoonpayCustomerId: jest.Mock; - getWalletRegistrationStatus: jest.Mock; - registerSelfHostedWallet: jest.Mock; - signPersonalMessage: jest.Mock; performGetStorage: jest.Mock; performSetStorage: jest.Mock; }; @@ -2738,10 +2535,6 @@ const SERVICE_ACTIONS = [ 'KycService:createUkycSession', 'KycService:createJourney', 'KycService:getSessionStatus', - 'KycService:getMoonpayCustomerId', - 'KycService:getWalletRegistrationStatus', - 'KycService:registerSelfHostedWallet', - 'KeyringController:signPersonalMessage', 'UserStorageController:performGetStorage', 'UserStorageController:performSetStorage', ] as const; @@ -2826,21 +2619,6 @@ function withController( .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), - getMoonpayCustomerId: jest.fn().mockResolvedValue('iron-customer-fallback'), - getWalletRegistrationStatus: jest - .fn() - .mockResolvedValue({ type: 'absent' }), - registerSelfHostedWallet: jest.fn().mockResolvedValue({ - type: 'registered', - registration: { - id: 'wallet-1', - address: '0xabc', - blockchain: 'Monad', - disabled: false, - isSelf: true, - }, - }), - signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), performGetStorage: jest.fn().mockResolvedValue(null), performSetStorage: jest.fn().mockResolvedValue(undefined), }; @@ -2900,22 +2678,6 @@ function withController( 'KycService:getSessionStatus', handlers.getSessionStatus, ); - rootMessenger.registerActionHandler( - 'KycService:getMoonpayCustomerId', - handlers.getMoonpayCustomerId, - ); - rootMessenger.registerActionHandler( - 'KycService:getWalletRegistrationStatus', - handlers.getWalletRegistrationStatus, - ); - rootMessenger.registerActionHandler( - 'KycService:registerSelfHostedWallet', - handlers.registerSelfHostedWallet, - ); - rootMessenger.registerActionHandler( - 'KeyringController:signPersonalMessage', - handlers.signPersonalMessage, - ); rootMessenger.registerActionHandler( 'UserStorageController:performGetStorage', handlers.performGetStorage, diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index b9dfc3b2b14..29e7f21e12e 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -4,13 +4,12 @@ import type { StateMetadata, } from '@metamask/base-controller'; import { BaseController } from '@metamask/base-controller'; -import type { KeyringControllerSignPersonalMessageAction } from '@metamask/keyring-controller'; import type { Messenger } from '@metamask/messenger'; import type { UserStorageControllerPerformGetStorageAction, UserStorageControllerPerformSetStorageAction, } from '@metamask/profile-sync-controller/user-storage'; -import type { Hex, Json } from '@metamask/utils'; +import type { Json } from '@metamask/utils'; import { x25519 } from '@noble/curves/ed25519'; import { decryptCredentials, generateKeyPair } from './crypto.js'; @@ -18,7 +17,6 @@ import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto.js'; import { toBase64Url } from './encoding.js'; import type { KycControllerMethodActions } from './KycController-method-action-types.js'; import type { KycServiceMethodActions } from './KycService-method-action-types.js'; -import { buildOwnershipMessage } from './ownership-message.js'; import type { KycCustomerIdentity, KycDisclaimer, @@ -36,18 +34,6 @@ import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js'; import type { UkycLocalUserSecretStore } from './ukyc/localUserSecret.js'; import { signStorageAccessToken } from './ukyc/storageAccessToken.js'; import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js'; -import { - createInitialState, - transition as transitionWalletRegistration, -} from './wallet-registration-machine.js'; -import { - createIdempotencyKey, - WalletRegistrationError, -} from './wallet-registration-service.js'; -import type { - RegistrationStatus, - SelfHostedRegistration, -} from './wallet-registration-service.js'; // === GENERAL === @@ -394,7 +380,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'refreshKycStatus', 'startSumSub', 'getSessionStatus', - 'registerMoneyAccountWallet', 'reset', ] as const; @@ -409,7 +394,6 @@ export type KycControllerActions = type AllowedActions = | KycServiceMethodActions - | KeyringControllerSignPersonalMessageAction | UserStorageControllerPerformGetStorageAction | UserStorageControllerPerformSetStorageAction; @@ -469,16 +453,6 @@ export type KycControllerOptions = { userStatusPollIntervalMs?: number; }; -export type MoneyAccountWalletRegistrationResult = - | { - type: 'registered' | 'alreadyRegistered'; - registration: SelfHostedRegistration; - } - | { - type: 'registeredDisabled'; - registration: SelfHostedRegistration; - }; - /** * The shape of a message posted by a Check/Auth frame. */ @@ -1840,184 +1814,6 @@ export class KycController extends BaseController< } } - /** - * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. - * - * Consumers provide only the Monad address. The controller reuses the Iron - * customer id captured from MoonPay's hosted frame when available, otherwise - * it resolves the id via `GET /neobank/customers/{external_id}/external` - * (MetaMask canonical profile id). Customer resolution happens before the - * first list/lookup because list requires `customer_id` in the path. - * Message construction, signing, submission, and ambiguous-write - * reconciliation stay internal to KYC. - * - * @param params - Money Account wallet registration parameters. - * @param params.address - Monad Money Account address. - * @returns The successful registration state. - */ - async registerMoneyAccountWallet({ - address, - }: { - address: Hex; - }): Promise { - let machine = transitionWalletRegistration(createInitialState(), { - type: 'START', - }); - - const toExistingResult = ( - status: RegistrationStatus, - ): MoneyAccountWalletRegistrationResult | undefined => { - if (status.type === 'active') { - return { type: 'alreadyRegistered', registration: status.registration }; - } - if (status.type === 'disabled') { - return { - type: 'registeredDisabled', - registration: status.registration, - }; - } - return undefined; - }; - - // List requires customer_id in the neobank path, so resolve Iron's id - // before the first lookup. Prefer the ephemeral frame-captured value. - const customerId = - this.state.moonpayCustomerId ?? - (await this.messenger.call('KycService:getMoonpayCustomerId')); - - const lookup = async (): Promise => { - try { - return await this.messenger.call( - 'KycService:getWalletRegistrationStatus', - { customerId, address }, - ); - } catch (error) { - machine = transitionWalletRegistration(machine, { - type: 'LOOKUP_FAILED', - }); - throw error; - } - }; - - const applyLookup = ( - status: RegistrationStatus, - ): MoneyAccountWalletRegistrationResult | undefined => { - let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = - 'LOOKUP_ABSENT'; - if (status.type === 'active') { - eventType = 'LOOKUP_ACTIVE'; - } else if (status.type === 'disabled') { - eventType = 'LOOKUP_DISABLED'; - } - machine = transitionWalletRegistration(machine, { - type: eventType, - }); - return toExistingResult(status); - }; - - const existingStatus = await lookup(); - const existingResult = applyLookup(existingStatus); - if (existingResult) { - return existingResult; - } - - // Stable across transient retries of the same ownership proof; refreshed - // when the UTC-dated message must be rebuilt and re-signed. - let idempotencyKey = createIdempotencyKey(); - let lastMessage: string | undefined; - - while (true) { - const message = buildOwnershipMessage({ - address, - customerId, - now: new Date(), - }); - if (lastMessage !== undefined && message !== lastMessage) { - idempotencyKey = createIdempotencyKey(); - } - lastMessage = message; - - let signature: string; - try { - signature = await this.messenger.call( - 'KeyringController:signPersonalMessage', - { data: message, from: address }, - ); - machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); - } catch (error) { - machine = transitionWalletRegistration(machine, { - type: 'SIGN_FAILED', - retryable: false, - }); - throw error; - } - - try { - const result = await this.messenger.call( - 'KycService:registerSelfHostedWallet', - { - address, - customerId, - message, - signature, - idempotencyKey, - }, - ); - machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); - return result; - } catch (error) { - if (!(error instanceof WalletRegistrationError)) { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TERMINAL', - }); - throw error; - } - - if (error.kind === 'conflict') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_CONFLICT', - }); - } else if (error.kind === 'transient') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TRANSIENT', - }); - } else if (error.kind === 'validation') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_VALIDATION', - utcRollover: - buildOwnershipMessage({ - address, - customerId, - now: new Date(), - }) !== message, - }); - } else if (error.kind === 'rateLimited') { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_RATE_LIMITED', - }); - } else { - machine = transitionWalletRegistration(machine, { - type: 'SUBMIT_TERMINAL', - }); - } - - if ( - machine.status === 'disambiguate409' || - machine.status === 'checkThenRetry' - ) { - const reconciledResult = applyLookup(await lookup()); - if (reconciledResult) { - return reconciledResult; - } - } - - if (machine.status !== 'signing') { - throw error; - } - } - } - } - /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index e5458df0d95..7f38f86aa14 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -17,42 +17,6 @@ export type KycServiceGetGeoCountryAction = { handler: KycService['getGeoCountry']; }; -/** - * Resolves Iron's internal customer id via neobank-proxy customer lookup, - * using the MetaMask canonical profile id as the partner `external_id`. - * - * @returns Iron's internal customer id. - */ -export type KycServiceGetMoonpayCustomerIdAction = { - type: `KycService:getMoonpayCustomerId`; - handler: KycService['getMoonpayCustomerId']; -}; - -/** - * Checks whether a Monad Money Account address is already registered for the - * given Iron customer. - * - * @param params - Customer id and address to check. - * @param params.customerId - Iron / MoonPay customer UUID. - * @param params.address - Money Account address. - * @returns Active, disabled, or absent registration status. - */ -export type KycServiceGetWalletRegistrationStatusAction = { - type: `KycService:getWalletRegistrationStatus`; - handler: KycService['getWalletRegistrationStatus']; -}; - -/** - * Submits a signed Monad Money Account ownership proof. - * - * @param params - Signed ownership proof. - * @returns Registered wallet record. - */ -export type KycServiceRegisterSelfHostedWalletAction = { - type: `KycService:registerSelfHostedWallet`; - handler: KycService['registerSelfHostedWallet']; -}; - /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -226,9 +190,6 @@ export type KycServiceGetSessionStatusAction = { */ export type KycServiceMethodActions = | KycServiceGetGeoCountryAction - | KycServiceGetMoonpayCustomerIdAction - | KycServiceGetWalletRegistrationStatusAction - | KycServiceRegisterSelfHostedWalletAction | KycServiceFetchDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index efe4c4ad95d..c7d5d6c9db9 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -59,92 +59,6 @@ describe('KycService', () => { }); }); - describe('Money Account wallet registration', () => { - it('resolves the Iron customer id via neobank customer lookup', async () => { - nock(MOCK_API_URL) - .get('/neobank/customers/canonical-profile-1/external') - .matchHeader('authorization', 'Bearer test-bearer') - .reply(200, { - id: 'iron-customer-1', - external_id: 'canonical-profile-1', - }); - - const { service } = getService(); - - expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); - }); - - it('checks Monad wallet registration status for a customer', async () => { - nock(MOCK_API_URL) - .get('/neobank/addresses/crypto/iron-customer-1') - .query({ filter: 'SelfHosted' }) - .reply(200, []); - - const { service } = getService(); - - expect( - await service.getWalletRegistrationStatus({ - customerId: 'iron-customer-1', - address: '0xabc', - }), - ).toStrictEqual({ type: 'absent' }); - }); - - it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { - nock(MOCK_API_URL) - .post( - '/neobank/addresses/crypto/selfhosted', - { - customer_id: 'iron-customer-1', - address: '0xabc', - blockchain: 'Monad', - message: 'ownership message', - signature: '0xsig', - }, - { reqheaders: { 'idempotency-key': 'idem-1' } }, - ) - .reply(200, { - id: 'wallet-1', - address: '0xabc', - disabled: false, - }); - - const { service } = getService(); - - expect( - await service.registerSelfHostedWallet({ - customerId: 'iron-customer-1', - address: '0xabc', - message: 'ownership message', - signature: '0xsig', - idempotencyKey: 'idem-1', - }), - ).toMatchObject({ - type: 'registered', - registration: { id: 'wallet-1', blockchain: 'Monad' }, - }); - }); - - it('uses neobankBaseUrl when provided for wallet routes', async () => { - const neobankUrl = 'https://on-ramp.dev-api.cx.metamask.io'; - nock(neobankUrl) - .get('/neobank/customers/canonical-profile-1/external') - .reply(200, { id: 'iron-customer-1' }); - - const { service } = getService({ neobankBaseUrl: neobankUrl }); - - expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); - }); - - it('throws when the session profile has no usable external id', async () => { - const { service } = getService({ canonicalProfileId: '' }); - - await expect(service.getMoonpayCustomerId()).rejects.toThrow( - /Unable to resolve MetaMask canonical profile id/u, - ); - }); - }); - describe('fetchDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ @@ -837,11 +751,8 @@ type RootMessenger = Messenger< * @param args.geolocation - The location the geolocation handler returns. * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. * @param args.baseUrl - Base URL of the KYC API. - * @param args.neobankBaseUrl - Optional on-ramp / neobank-proxy base URL. * @param args.fractalEncryptionBaseUrl - Fractal base URL; `null` omits the * option so the service falls back to an empty string. - * @param args.canonicalProfileId - Canonical profile id returned by - * `AuthenticationController:getSessionProfile`. * @returns The service, root messenger, and service messenger. */ function getService({ @@ -849,19 +760,15 @@ function getService({ geolocation = 'US-NY', defaultPolicy = false, baseUrl = MOCK_API_URL, - neobankBaseUrl, // `null` means "omit the option entirely" (exercises the constructor's // `?? ''` fallback); omitting the field defaults to the mock Fractal URL. fractalEncryptionBaseUrl = MOCK_FRACTAL_URL, - canonicalProfileId = 'canonical-profile-1', }: { bearerToken?: string; geolocation?: string | null; defaultPolicy?: boolean; baseUrl?: string; - neobankBaseUrl?: string; fractalEncryptionBaseUrl?: string | null; - canonicalProfileId?: string; } = {}): { service: KycService; rootMessenger: RootMessenger; @@ -877,7 +784,6 @@ function getService({ rootMessenger.delegate({ actions: [ 'AuthenticationController:getBearerToken', - 'AuthenticationController:getSessionProfile', 'GeolocationController:getGeolocation', ], events: [], @@ -887,15 +793,6 @@ function getService({ 'AuthenticationController:getBearerToken', async () => bearerToken, ); - rootMessenger.registerActionHandler( - 'AuthenticationController:getSessionProfile', - async () => ({ - identifierId: 'id-1', - profileId: canonicalProfileId, - canonicalProfileId, - metaMetricsId: 'mm-1', - }), - ); rootMessenger.registerActionHandler( 'GeolocationController:getGeolocation', async () => geolocation as string, @@ -905,7 +802,6 @@ function getService({ fetch, messenger, baseUrl, - ...(neobankBaseUrl === undefined ? {} : { neobankBaseUrl }), ...(fractalEncryptionBaseUrl === null ? {} : { fractalEncryptionBaseUrl }), ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), }); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 624b88de456..89201eb63a0 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -8,10 +8,7 @@ import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; import { HttpError } from '@metamask/controller-utils'; import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; import type { Messenger } from '@metamask/messenger'; -import type { - AuthenticationControllerGetBearerTokenAction, - AuthenticationControllerGetSessionProfileAction, -} from '@metamask/profile-sync-controller/auth'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; import type { Infer, Struct } from '@metamask/superstruct'; import { array, @@ -38,11 +35,6 @@ import type { import { UKYC_JWKS_PATH } from './ukyc/constants.js'; import { encodeStorageAccessTokenForHeader } from './ukyc/storageAccessToken.js'; import type { UkycStorageAccessToken } from './ukyc/storageAccessToken.js'; -import { WalletRegistrationService } from './wallet-registration-service.js'; -import type { - RegistrationOutcome, - RegistrationStatus, -} from './wallet-registration-service.js'; // === GENERAL === @@ -68,9 +60,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'createUkycSession', 'createJourney', 'getSessionStatus', - 'getMoonpayCustomerId', - 'getWalletRegistrationStatus', - 'registerSelfHostedWallet', ] as const; /** @@ -91,7 +80,6 @@ export type KycServiceActions = */ type AllowedActions = | AuthenticationControllerGetBearerTokenAction - | AuthenticationControllerGetSessionProfileAction | GeolocationControllerGetGeolocationAction; /** @@ -139,13 +127,6 @@ export type KycServiceOptions = { * Mandatory value that sets the base url to KYC api */ baseUrl: string; - /** - * Base URL of the on-ramp / neobank-proxy host used for Money Account wallet - * registration (e.g. `https://on-ramp.dev-api.cx.metamask.io`). Paths are - * under `/neobank`. When omitted, falls back to {@link baseUrl} so local - * tests can target a single mock host. - */ - neobankBaseUrl?: string; /** * Base URL of the Fractal encryption service, from which the JWKS used to * verify the `jwtChain` returned by {@link KycService.getWrappingKey} is @@ -322,23 +303,6 @@ export type GetSessionStatusParams = { sessionId: string; }; -export type GetWalletRegistrationStatusParams = { - customerId: string; - address: string; -}; - -export type RegisterSelfHostedWalletParams = { - customerId: string; - address: string; - message: string; - signature: string; - /** - * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable - * key across retries of the same ownership body. - */ - idempotencyKey?: string; -}; - // === SERVICE DEFINITION === /** @@ -364,8 +328,6 @@ export class KycService extends BaseDataService< readonly #fractalEncryptionBaseUrl: string; - readonly #walletRegistrationService: WalletRegistrationService; - /** * Constructs a new KycService. * @@ -373,8 +335,6 @@ export class KycService extends BaseDataService< * @param options.messenger - The messenger suited for this service. * @param options.fetch - A function used to make HTTP requests. * @param options.baseUrl - Base URL of the KYC API - * @param options.neobankBaseUrl - Base URL of the neobank-proxy host for - * wallet registration. Defaults to `baseUrl` when omitted. * @param options.fractalEncryptionBaseUrl - Base URL of the Fractal * encryption service, from which the JWKS used to verify the wrapping-key * `jwtChain` is fetched. @@ -386,7 +346,6 @@ export class KycService extends BaseDataService< messenger, fetch: fetchFunction, baseUrl, - neobankBaseUrl, fractalEncryptionBaseUrl, queryClientConfig = {}, policyOptions = {}, @@ -403,13 +362,6 @@ export class KycService extends BaseDataService< } this.#baseUrl = baseUrl; this.#fractalEncryptionBaseUrl = fractalEncryptionBaseUrl ?? ''; - this.#walletRegistrationService = new WalletRegistrationService({ - fetch: fetchFunction, - baseUrl: neobankBaseUrl ?? baseUrl, - getAuthToken: async (): Promise => this.#getBearerToken(), - getExternalId: async (): Promise => - this.#getCanonicalExternalId(), - }); this.messenger.registerMethodActionHandlers( this, MESSENGER_EXPOSED_METHODS, @@ -449,51 +401,6 @@ export class KycService extends BaseDataService< return alpha3; } - /** - * Resolves Iron's internal customer id via neobank-proxy customer lookup, - * using the MetaMask canonical profile id as the partner `external_id`. - * - * @returns Iron's internal customer id. - */ - async getMoonpayCustomerId(): Promise { - return await this.#walletRegistrationService.getMoonpayCustomerId(); - } - - /** - * Checks whether a Monad Money Account address is already registered for the - * given Iron customer. - * - * @param params - Customer id and address to check. - * @param params.customerId - Iron / MoonPay customer UUID. - * @param params.address - Money Account address. - * @returns Active, disabled, or absent registration status. - */ - async getWalletRegistrationStatus({ - customerId, - address, - }: GetWalletRegistrationStatusParams): Promise { - return await this.#walletRegistrationService.getRegistrationStatus({ - customerId, - address, - blockchain: 'Monad', - }); - } - - /** - * Submits a signed Monad Money Account ownership proof. - * - * @param params - Signed ownership proof. - * @returns Registered wallet record. - */ - async registerSelfHostedWallet( - params: RegisterSelfHostedWalletParams, - ): Promise { - return await this.#walletRegistrationService.registerSelfHostedWallet({ - ...params, - blockchain: 'Monad', - }); - } - /** * Fetches the disclaimers the customer must accept before a session is * created. @@ -946,29 +853,6 @@ export class KycService extends BaseDataService< return bearerToken; } - /** - * Resolves the MetaMask canonical profile id used as MoonPay's partner - * `external_id` for neobank customer lookup. - * - * @returns Canonical profile id. - */ - async #getCanonicalExternalId(): Promise { - const profile = await this.messenger.call( - 'AuthenticationController:getSessionProfile', - ); - const canonical = profile?.canonicalProfileId; - const externalId = - typeof canonical === 'string' && canonical.length > 0 - ? canonical - : profile?.profileId; - if (typeof externalId !== 'string' || externalId.length === 0) { - throw new Error( - 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', - ); - } - return externalId; - } - /** * Performs a single JSON request. * diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts index 3bbd3b4c258..f986f8847a4 100644 --- a/packages/kyc-controller/src/index.test.ts +++ b/packages/kyc-controller/src/index.test.ts @@ -12,7 +12,6 @@ describe('@metamask/kyc-controller', () => { alpha2ToAlpha3: expect.any(Function), generateKeyPair: expect.any(Function), decryptCredentials: expect.any(Function), - WalletRegistrationError: expect.any(Function), controllerName: 'KycController', serviceName: 'KycService', }); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 3bd8e5778f6..d6b24b3730a 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -8,7 +8,6 @@ export type { KycControllerEvents, KycControllerGetStateAction, KycControllerMessenger, - MoneyAccountWalletRegistrationResult, KycControllerOptions, KycControllerState, KycControllerStateChangeEvent, @@ -30,7 +29,6 @@ export type { KycControllerLoadDisclaimersAction, KycControllerRefreshKycStatusAction, KycControllerResetAction, - KycControllerRegisterMoneyAccountWalletAction, KycControllerStartSumSubAction, } from './KycController-method-action-types.js'; @@ -42,7 +40,6 @@ export type { CreateSessionParams, CreateUkycSessionParams, GetSessionStatusParams, - GetWalletRegistrationStatusParams, GetWrappingKeyParams, IronCustomerResponse, JwksResponse, @@ -53,7 +50,6 @@ export type { KycServiceInvalidateQueriesAction, KycServiceMessenger, KycServiceOptions, - RegisterSelfHostedWalletParams, SubmitConsentsParams, UkycSessionResponse, WrappedEncryptionKey, @@ -71,11 +67,8 @@ export type { KycServiceFetchJwksAction, KycServiceFetchKycStatusAction, KycServiceGetGeoCountryAction, - KycServiceGetMoonpayCustomerIdAction, KycServiceGetSessionStatusAction, - KycServiceGetWalletRegistrationStatusAction, KycServiceGetWrappingKeyAction, - KycServiceRegisterSelfHostedWalletAction, KycServiceSubmitConsentsAction, } from './KycService-method-action-types.js'; @@ -141,9 +134,3 @@ export type { MintedUkycTestToken, MintUkycTestTokenParams, } from './ukyc/testToken.js'; - -export type { - SelfHostedRegistration, - WalletRegistrationErrorKind, -} from './wallet-registration-service.js'; -export { WalletRegistrationError } from './wallet-registration-service.js'; diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json index 6f6e3a6ef0d..d355169e16c 100644 --- a/packages/kyc-controller/tsconfig.build.json +++ b/packages/kyc-controller/tsconfig.build.json @@ -10,7 +10,6 @@ { "path": "../base-data-service/tsconfig.build.json" }, { "path": "../controller-utils/tsconfig.build.json" }, { "path": "../geolocation-controller/tsconfig.build.json" }, - { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../profile-sync-controller/tsconfig.build.json" } ], diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json index 34495606a6a..1079229158f 100644 --- a/packages/kyc-controller/tsconfig.json +++ b/packages/kyc-controller/tsconfig.json @@ -8,7 +8,6 @@ { "path": "../base-data-service" }, { "path": "../controller-utils" }, { "path": "../geolocation-controller" }, - { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../profile-sync-controller" } ], diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index e14984e756a..324e864aa1a 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -13,10 +13,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`KycController:getCustomerIdentity`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation. - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) +- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id (KYC session identity when available, otherwise the neobank-proxy external-id lookup), signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. +- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ### Changed -- Add a dependency on `@metamask/kyc-controller` so `RampsController` can resolve the vendor customer identity when creating autoramps. +- Resolve the vendor customer identity for autoramps via a locally declared `KycController:getCustomerIdentity` messenger action type instead of a package dependency on `@metamask/kyc-controller`, keeping the ramps↔kyc packages decoupled in the monorepo build. - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ## [20.0.0] diff --git a/packages/ramps-controller/package.json b/packages/ramps-controller/package.json index 296d1c41405..36b5fafa5e8 100644 --- a/packages/ramps-controller/package.json +++ b/packages/ramps-controller/package.json @@ -58,7 +58,6 @@ "dependencies": { "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.3.0", - "@metamask/kyc-controller": "^0.0.0", "@metamask/messenger": "^2.0.0", "@metamask/profile-sync-controller": "^29.0.0", "@metamask/remote-feature-flag-controller": "^5.0.0" diff --git a/packages/ramps-controller/src/NeoBankService-method-action-types.ts b/packages/ramps-controller/src/NeoBankService-method-action-types.ts index 3343ed3ba87..956b7f2b6b4 100644 --- a/packages/ramps-controller/src/NeoBankService-method-action-types.ts +++ b/packages/ramps-controller/src/NeoBankService-method-action-types.ts @@ -94,6 +94,43 @@ export type NeoBankServiceGetCustomerByExternalIdAction = { handler: NeoBankService['getCustomerByExternalId']; }; +/** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ +export type NeoBankServiceGetMoonpayCustomerIdAction = { + type: `NeoBankService:getMoonpayCustomerId`; + handler: NeoBankService['getMoonpayCustomerId']; +}; + +/** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ +export type NeoBankServiceGetWalletRegistrationStatusAction = { + type: `NeoBankService:getWalletRegistrationStatus`; + handler: NeoBankService['getWalletRegistrationStatus']; +}; + +/** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ +export type NeoBankServiceRegisterSelfHostedWalletAction = { + type: `NeoBankService:registerSelfHostedWallet`; + handler: NeoBankService['registerSelfHostedWallet']; +}; + /** * Union of all NeoBankService action types. */ @@ -104,4 +141,7 @@ export type NeoBankServiceMethodActions = | NeoBankServiceCreateAutorampAction | NeoBankServiceGetAutorampQuoteForAutorampAction | NeoBankServiceAttachAutorampQuoteAction - | NeoBankServiceGetCustomerByExternalIdAction; + | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetMoonpayCustomerIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction; diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index c2f40d582ac..e30812e3460 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -14,14 +14,20 @@ const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; /** * Builds a NeoBankService with AuthenticationController bearer auth stubbed. * - * @param options - Optional constructor overrides. Pass `omitDefaults: true` to - * exercise constructor defaulted parameters (`environment`, `policyOptions`). + * @param options - Optional constructor overrides. + * @param options.environment - Ramp environment for host selection. + * @param options.baseUrlOverride - Overrides the environment-derived host. + * @param options.omitDefaults - Pass `true` to exercise constructor defaulted + * parameters (`environment`, `policyOptions`). + * @param options.canonicalProfileId - Canonical profile id returned by the + * stubbed `AuthenticationController:getSessionProfile` (wallet registration). * @returns Service instance for the test. */ function createService(options?: { environment?: RampsEnvironment; baseUrlOverride?: string; omitDefaults?: boolean; + canonicalProfileId?: string; }): NeoBankService { const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE as MockAnyNamespace, @@ -30,6 +36,18 @@ function createService(options?: { 'AuthenticationController:getBearerToken', async () => 'test-token', ); + const canonicalProfileId = + options?.canonicalProfileId ?? 'canonical-profile-1'; + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: canonicalProfileId, + canonicalProfileId, + metaMetricsId: 'mm-1', + }) as never, + ); const messenger = new Messenger({ namespace: 'NeoBankService', @@ -37,7 +55,10 @@ function createService(options?: { }) as unknown as NeoBankServiceMessenger; rootMessenger.delegate({ messenger, - actions: ['AuthenticationController:getBearerToken'], + actions: [ + 'AuthenticationController:getBearerToken', + 'AuthenticationController:getSessionProfile', + ], }); if (options?.omitDefaults) { @@ -342,6 +363,92 @@ describe('NeoBankService', () => { }); }); + describe('Money Account wallet registration', () => { + it('resolves the Iron customer id via neobank customer lookup', async () => { + nock(STAGING_BASE) + .get('/neobank/customers/canonical-profile-1/external') + .matchHeader('authorization', 'Bearer test-token') + .reply(200, { + id: 'iron-customer-1', + external_id: 'canonical-profile-1', + }); + + const service = createService(); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('checks Monad wallet registration status for a customer', async () => { + nock(STAGING_BASE) + .get('/neobank/addresses/crypto/iron-customer-1') + .query({ filter: 'SelfHosted' }) + .reply(200, []); + + const service = createService(); + + expect( + await service.getWalletRegistrationStatus({ + customerId: 'iron-customer-1', + address: '0xabc', + }), + ).toStrictEqual({ type: 'absent' }); + }); + + it('submits a signed Monad wallet ownership proof with Idempotency-Key', async () => { + nock(STAGING_BASE) + .post( + '/neobank/addresses/crypto/selfhosted', + { + customer_id: 'iron-customer-1', + address: '0xabc', + blockchain: 'Monad', + message: 'ownership message', + signature: '0xsig', + }, + { reqheaders: { 'idempotency-key': 'idem-1' } }, + ) + .reply(200, { + id: 'wallet-1', + address: '0xabc', + disabled: false, + }); + + const service = createService(); + + expect( + await service.registerSelfHostedWallet({ + customerId: 'iron-customer-1', + address: '0xabc', + message: 'ownership message', + signature: '0xsig', + idempotencyKey: 'idem-1', + }), + ).toMatchObject({ + type: 'registered', + registration: { id: 'wallet-1', blockchain: 'Monad' }, + }); + }); + + it('uses the baseUrlOverride host for wallet routes', async () => { + const overrideUrl = 'https://on-ramp.dev-api.cx.metamask.io'; + nock(overrideUrl) + .get('/neobank/customers/canonical-profile-1/external') + .reply(200, { id: 'iron-customer-1' }); + + const service = createService({ baseUrlOverride: overrideUrl }); + + expect(await service.getMoonpayCustomerId()).toBe('iron-customer-1'); + }); + + it('throws when the session profile has no usable external id', async () => { + const service = createService({ canonicalProfileId: '' }); + + await expect(service.getMoonpayCustomerId()).rejects.toThrow( + /Unable to resolve MetaMask canonical profile id/u, + ); + }); + }); + describe('environments and policy hooks', () => { it.each([ [RampsEnvironment.Production, 'https://on-ramp.api.cx.metamask.io'], diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index e13d8b1ef1d..832a7ee74a6 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -13,6 +13,11 @@ import type { } from './autorampAccount.js'; import type { NeoBankServiceMethodActions } from './NeoBankService-method-action-types.js'; import { RAMPS_SDK_VERSION, RampsEnvironment } from './RampsService.js'; +import { WalletRegistrationService } from './wallet-registration-service.js'; +import type { + RegistrationOutcome, + RegistrationStatus, +} from './wallet-registration-service.js'; /** * Name of the NeoBankService messenger namespace. @@ -59,6 +64,23 @@ export type NeoBankQueryParams = Record< string | number | boolean | undefined | null >; +export type GetWalletRegistrationStatusParams = { + customerId: string; + address: string; +}; + +export type RegisterSelfHostedWalletParams = { + customerId: string; + address: string; + message: string; + signature: string; + /** + * Forwarded as `Idempotency-Key` on the neobank-proxy POST. Prefer a stable + * key across retries of the same ownership body. + */ + idempotencyKey?: string; +}; + const MESSENGER_EXPOSED_METHODS = [ 'getAutoramp', 'registerPixAddress', @@ -67,6 +89,9 @@ const MESSENGER_EXPOSED_METHODS = [ 'getAutorampQuoteForAutoramp', 'attachAutorampQuote', 'getCustomerByExternalId', + 'getMoonpayCustomerId', + 'getWalletRegistrationStatus', + 'registerSelfHostedWallet', ] as const; /** @@ -75,7 +100,8 @@ const MESSENGER_EXPOSED_METHODS = [ export type NeoBankServiceActions = NeoBankServiceMethodActions; type AllowedActions = - AuthenticationController.AuthenticationControllerGetBearerTokenAction; + | AuthenticationController.AuthenticationControllerGetBearerTokenAction + | AuthenticationController.AuthenticationControllerGetSessionProfileAction; export type NeoBankServiceEvents = never; @@ -178,6 +204,8 @@ export class NeoBankService { readonly #baseUrlOverride?: string; + #walletRegistrationService: WalletRegistrationService | undefined; + constructor({ messenger, environment = RampsEnvironment.Staging, @@ -214,6 +242,25 @@ export class NeoBankService { return getBaseUrl(this.#environment); } + /** + * Lazily builds the wallet registration client. Deferred so constructing the + * service never resolves the base URL eagerly (an invalid environment only + * throws when a request is made, matching the other neo-bank methods). + * + * @returns The wallet registration client. + */ + #getWalletRegistrationService(): WalletRegistrationService { + this.#walletRegistrationService ??= new WalletRegistrationService({ + fetch: this.#fetch, + baseUrl: this.#getBaseUrl(), + getAuthToken: async (): Promise => + this.#messenger.call('AuthenticationController:getBearerToken'), + getExternalId: async (): Promise => + this.#getCanonicalExternalId(), + }); + return this.#walletRegistrationService; + } + async #getRequestHeaders( options: NeoBankRequestOptions = {}, ): Promise> { @@ -405,6 +452,75 @@ export class NeoBankService { ); } + /** + * Resolves Iron's internal customer id via neobank-proxy customer lookup, + * using the MetaMask canonical profile id as the partner `external_id`. + * + * @returns Iron's internal customer id. + */ + async getMoonpayCustomerId(): Promise { + return await this.#getWalletRegistrationService().getMoonpayCustomerId(); + } + + /** + * Checks whether a Monad Money Account address is already registered for the + * given Iron customer. + * + * @param params - Customer id and address to check. + * @param params.customerId - Iron / MoonPay customer UUID. + * @param params.address - Money Account address. + * @returns Active, disabled, or absent registration status. + */ + async getWalletRegistrationStatus({ + customerId, + address, + }: GetWalletRegistrationStatusParams): Promise { + return await this.#getWalletRegistrationService().getRegistrationStatus({ + customerId, + address, + blockchain: 'Monad', + }); + } + + /** + * Submits a signed Monad Money Account ownership proof via neobank-proxy + * `POST /neobank/addresses/crypto/selfhosted`. + * + * @param params - Signed ownership proof. + * @returns Registered wallet record. + */ + async registerSelfHostedWallet( + params: RegisterSelfHostedWalletParams, + ): Promise { + return await this.#getWalletRegistrationService().registerSelfHostedWallet({ + ...params, + blockchain: 'Monad', + }); + } + + /** + * Resolves the MetaMask canonical profile id used as MoonPay's partner + * `external_id` for neobank customer lookup. + * + * @returns Canonical profile id. + */ + async #getCanonicalExternalId(): Promise { + const profile = await this.#messenger.call( + 'AuthenticationController:getSessionProfile', + ); + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { + throw new Error( + 'Unable to resolve MetaMask canonical profile id for MoonPay customer lookup', + ); + } + return externalId; + } + onRetry( listener: Parameters[0], ): ReturnType { diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index 75c4077fa58..d15a6ea8898 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -297,11 +297,10 @@ export type RampsControllerAddAutorampAction = { * Creates an autoramp via the Ramp API neo-bank proxy and applies the * returned snapshot locally. * - * The MoonPay `customer_id` is not accepted from callers: it is resolved from - * the KYC controller's session-scoped identity and injected into the request. - * This keeps the sensitive customer id owned by the KYC controller and avoids - * requiring the UI to know or plumb it. Throws when no verified identity is - * available yet. + * The MoonPay `customer_id` is not accepted from callers: it is resolved via + * {@link RampsController.resolveAutorampCustomerId} and injected into the + * request. This keeps the sensitive customer id owned by KYC / the neo-bank + * proxy and avoids requiring the UI to know or plumb it. * * @param request - CreateAutoramp payload (any `customer_id` is overwritten). * @param options - Optional idempotency key forwarded to the proxy. @@ -313,6 +312,25 @@ export type RampsControllerCreateAutorampAction = { handler: RampsController['createAutoramp']; }; +/** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC + * session identity when available, otherwise the neobank-proxy external-id + * lookup) before the first list/lookup because list requires `customer_id` + * in the path. Message construction, EIP-191 signing, submission, and + * ambiguous-write reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ +export type RampsControllerRegisterMoneyAccountWalletAction = { + type: `RampsController:registerMoneyAccountWallet`; + handler: RampsController['registerMoneyAccountWallet']; +}; + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. @@ -790,6 +808,7 @@ export type RampsControllerMethodActions = | RampsControllerRemoveOrderAction | RampsControllerAddAutorampAction | RampsControllerCreateAutorampAction + | RampsControllerRegisterMoneyAccountWalletAction | RampsControllerRemoveAutorampAction | RampsControllerMarkAutorampAsNotifiedAction | RampsControllerApplyAutorampStatusFromPushAction diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index c75195cf098..92843826224 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -50,6 +50,7 @@ import type { } from './RampsService.js'; import { RampsOrderStatus } from './RampsService.js'; import { RequestStatus } from './RequestCache.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; import type { TransakAccessToken, TransakUserDetails, @@ -9065,12 +9066,25 @@ describe('RampsController', () => { }); }); - it('throws when no KYC customer identity is available', async () => { + it('throws when no KYC identity or mapped external customer is available', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'KycController:getCustomerIdentity', () => null, ); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => null, + ); const createAutoramp = jest.fn(); rootMessenger.registerActionHandler( 'NeoBankService:createAutoramp', @@ -9078,7 +9092,7 @@ describe('RampsController', () => { ); await expect(controller.createAutoramp({})).rejects.toThrow( - /no verified KYC customer identity/u, + /no MoonPay customer is mapped to external id "profile-1"/u, ); expect(createAutoramp).not.toHaveBeenCalled(); }); @@ -9185,6 +9199,267 @@ describe('RampsController', () => { }); }); + describe('registerMoneyAccountWallet', () => { + const registration = { + id: 'wallet-1', + address: '0xabc', + blockchain: 'Monad' as const, + disabled: false, + isSelf: true, + }; + + type WalletRegistrationHandlers = { + getCustomerIdentity: jest.Mock; + getWalletRegistrationStatus: jest.Mock; + registerSelfHostedWallet: jest.Mock; + signPersonalMessage: jest.Mock; + }; + + /** + * Registers default handlers for every messenger action the wallet + * registration flow calls, returning the mocks for per-test overrides. + * + * @param rootMessenger - The root messenger of the controller under test. + * @returns The registered handler mocks. + */ + function registerWalletRegistrationHandlers( + rootMessenger: RootMessenger, + ): WalletRegistrationHandlers { + const handlers: WalletRegistrationHandlers = { + getCustomerIdentity: jest + .fn() + .mockReturnValue({ vendor: 'iron', id: 'iron-customer-1' }), + getWalletRegistrationStatus: jest + .fn() + .mockResolvedValue({ type: 'absent' }), + registerSelfHostedWallet: jest.fn().mockResolvedValue({ + type: 'registered', + registration, + }), + signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), + }; + rootMessenger.registerActionHandler( + 'KycController:getCustomerIdentity', + handlers.getCustomerIdentity, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getWalletRegistrationStatus', + handlers.getWalletRegistrationStatus, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:registerSelfHostedWallet', + handlers.registerSelfHostedWallet, + ); + rootMessenger.registerActionHandler( + 'KeyringController:signPersonalMessage', + handlers.signPersonalMessage, + ); + return handlers; + } + + it('returns an existing active registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'active', + registration, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-1', + address: '0xabc', + }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('returns an existing disabled registration without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus.mockResolvedValue({ + type: 'disabled', + registration: { ...registration, disabled: true }, + }); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registeredDisabled' }); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('signs and submits an ownership proof for an absent registration', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toMatchObject({ type: 'registered' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledWith({ + data: expect.stringContaining('as customer iron-customer-1.'), + from: '0xabc', + }); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledWith( + expect.objectContaining({ + address: '0xabc', + customerId: 'iron-customer-1', + signature: '0xsig', + idempotencyKey: expect.any(String), + }), + ); + }); + }); + + it('falls back to the external-id customer lookup when KYC has no identity', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getCustomerIdentity.mockReturnValue(null); + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'iron-customer-fallback' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ + customerId: 'iron-customer-fallback', + address: '0xabc', + }); + }); + }); + + it('reconciles an ambiguous conflict as already registered', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.getWalletRegistrationStatus + .mockResolvedValueOnce({ type: 'absent' }) + .mockResolvedValueOnce({ type: 'active', registration }); + handlers.registerSelfHostedWallet.mockRejectedValue( + new WalletRegistrationError('conflict', { httpStatus: 409 }), + ); + + expect( + await controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).toStrictEqual({ + type: 'alreadyRegistered', + registration, + }); + }); + }); + + it('rethrows a transient failure when reconciliation remains absent', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new WalletRegistrationError('transient', { + httpStatus: 502, + }); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(4); + expect(handlers.registerSelfHostedWallet).toHaveBeenCalledTimes(3); + }); + }); + + it('rebuilds and re-signs after a UTC date rollover', async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date('2026-08-12T23:59:59.999Z')); + try { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet + .mockImplementationOnce(async () => { + jest.setSystemTime(new Date('2026-08-13T00:00:00.000Z')); + throw new WalletRegistrationError('validation', { + httpStatus: 400, + }); + }) + .mockResolvedValueOnce({ + type: 'registered', + registration, + }); + + await controller.registerMoneyAccountWallet({ address: '0xabc' }); + + expect(handlers.signPersonalMessage).toHaveBeenCalledTimes(2); + expect(handlers.signPersonalMessage.mock.calls[0][0].data).toContain( + 'signed on 12/08/2026', + ); + expect(handlers.signPersonalMessage.mock.calls[1][0].data).toContain( + 'signed on 13/08/2026', + ); + }); + } finally { + jest.useRealTimers(); + } + }); + + it.each([ + new WalletRegistrationError('validation', { httpStatus: 400 }), + new WalletRegistrationError('rateLimited', { httpStatus: 429 }), + new WalletRegistrationError('unauthorized', { httpStatus: 401 }), + new Error('unexpected'), + ])('rethrows terminal registration failure %#', async (error) => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + handlers.registerSelfHostedWallet.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledTimes(1); + }); + }); + + it('rethrows an initial lookup failure without signing', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new Error('lookup failed'); + handlers.getWalletRegistrationStatus.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.signPersonalMessage).not.toHaveBeenCalled(); + }); + }); + + it('rethrows a signing failure without submitting', async () => { + await withController(async ({ controller, rootMessenger }) => { + const handlers = registerWalletRegistrationHandlers(rootMessenger); + const error = new Error('signing failed'); + handlers.signPersonalMessage.mockRejectedValue(error); + + await expect( + controller.registerMoneyAccountWallet({ address: '0xabc' }), + ).rejects.toBe(error); + expect(handlers.registerSelfHostedWallet).not.toHaveBeenCalled(); + }); + }); + }); + describe('addOrder', () => { const mockOrder = { id: '/providers/transak-staging/orders/abc-123', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index f8fe570ee35..40e8d1b37d9 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -15,7 +15,6 @@ import { isHeadlessAllProvidersEnabled, normalizeHeadlessProviderId, } from './featureFlags.js'; -import type { KycControllerGetCustomerIdentityAction } from '@metamask/kyc-controller'; import type { AutorampAccount, @@ -37,8 +36,23 @@ import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; +import { buildOwnershipMessage } from './ownership-message.js'; +import { + createInitialState as createInitialWalletRegistrationState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import type { UserStorageController } from '@metamask/profile-sync-controller'; import { @@ -196,6 +210,8 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ 'NeoBankService:getAutoramp', 'NeoBankService:createAutoramp', 'NeoBankService:getCustomerByExternalId', + 'NeoBankService:getWalletRegistrationStatus', + 'NeoBankService:registerSelfHostedWallet', ] as const satisfies readonly ( | RampsServiceActions['type'] | TransakServiceActions['type'] @@ -206,12 +222,50 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ * Other controller actions RampsController calls via the messenger. * Hosts that enable autoramp creation must delegate these from the root * messenger so the controller can resolve the vendor customer identity. + * `KeyringController:signPersonalMessage` is required for Money Account + * self-hosted wallet registration (EIP-191 ownership proof). */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ 'KycController:getCustomerIdentity', 'AuthenticationController:getSessionProfile', + 'KeyringController:signPersonalMessage', ] as const; +/** + * Structural type for the KYC controller's `getCustomerIdentity` messenger + * action. Declared locally (mirroring `@metamask/kyc-controller`) so this + * package does not need a dependency on the KYC package; the messenger only + * matches on the action `type` string, so the shapes stay compatible. + */ +export type KycControllerGetCustomerIdentityAction = { + type: 'KycController:getCustomerIdentity'; + handler: () => { vendor: string; id: string } | null; +}; + +/** + * Structural type for the keyring controller's `signPersonalMessage` messenger + * action (EIP-191). Declared locally (mirroring + * `@metamask/keyring-controller`) to avoid a package dependency for a single + * type-only messenger action. + */ +export type KeyringControllerSignPersonalMessageAction = { + type: 'KeyringController:signPersonalMessage'; + handler: (messageParams: { data: string; from: string }) => Promise; +}; + +/** + * Successful outcome of {@link RampsController.registerMoneyAccountWallet}. + */ +export type MoneyAccountWalletRegistrationResult = + | { + type: 'registered' | 'alreadyRegistered'; + registration: SelfHostedRegistration; + } + | { + type: 'registeredDisabled'; + registration: SelfHostedRegistration; + }; + /** * User Storage / auth actions needed for autoramp Backup & Sync. * Hosts that enable `syncAutorampsWithUserStorage` must also delegate these. @@ -706,7 +760,10 @@ type AllowedActions = | NeoBankServiceGetAutorampAction | NeoBankServiceCreateAutorampAction | NeoBankServiceGetCustomerByExternalIdAction + | NeoBankServiceGetWalletRegistrationStatusAction + | NeoBankServiceRegisterSelfHostedWalletAction | KycControllerGetCustomerIdentityAction + | KeyringControllerSignPersonalMessageAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction | UserStorageController.UserStorageControllerPerformBatchSetStorageAction @@ -903,6 +960,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'addAutoramp', 'createAutoramp', 'removeAutoramp', + 'registerMoneyAccountWallet', 'markAutorampAsNotified', 'applyAutorampStatusFromPush', 'refreshAutoramp', @@ -2728,6 +2786,182 @@ export class RampsController extends BaseController< return customerId; } + /** + * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. + * + * Consumers provide only the Monad address. The controller resolves the Iron + * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC + * session identity when available, otherwise the neobank-proxy external-id + * lookup) before the first list/lookup because list requires `customer_id` + * in the path. Message construction, EIP-191 signing, submission, and + * ambiguous-write reconciliation stay internal to this controller. + * + * @param params - Money Account wallet registration parameters. + * @param params.address - Monad Money Account address. + * @returns The successful registration state. + */ + async registerMoneyAccountWallet({ + address, + }: { + address: string; + }): Promise { + let machine = transitionWalletRegistration( + createInitialWalletRegistrationState(), + { type: 'START' }, + ); + + const toExistingResult = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + if (status.type === 'active') { + return { type: 'alreadyRegistered', registration: status.registration }; + } + if (status.type === 'disabled') { + return { + type: 'registeredDisabled', + registration: status.registration, + }; + } + return undefined; + }; + + // List requires customer_id in the neobank path, so resolve Iron's id + // before the first lookup. + const customerId = await this.resolveAutorampCustomerId(); + + const lookup = async (): Promise => { + try { + return await this.messenger.call( + 'NeoBankService:getWalletRegistrationStatus', + { customerId, address }, + ); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'LOOKUP_FAILED', + }); + throw error; + } + }; + + const applyLookup = ( + status: RegistrationStatus, + ): MoneyAccountWalletRegistrationResult | undefined => { + let eventType: 'LOOKUP_ACTIVE' | 'LOOKUP_DISABLED' | 'LOOKUP_ABSENT' = + 'LOOKUP_ABSENT'; + if (status.type === 'active') { + eventType = 'LOOKUP_ACTIVE'; + } else if (status.type === 'disabled') { + eventType = 'LOOKUP_DISABLED'; + } + machine = transitionWalletRegistration(machine, { + type: eventType, + }); + return toExistingResult(status); + }; + + const existingStatus = await lookup(); + const existingResult = applyLookup(existingStatus); + if (existingResult) { + return existingResult; + } + + // Stable across transient retries of the same ownership proof; refreshed + // when the UTC-dated message must be rebuilt and re-signed. + let idempotencyKey = createIdempotencyKey(); + let lastMessage: string | undefined; + + while (true) { + const message = buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }); + if (lastMessage !== undefined && message !== lastMessage) { + idempotencyKey = createIdempotencyKey(); + } + lastMessage = message; + + let signature: string; + try { + signature = await this.messenger.call( + 'KeyringController:signPersonalMessage', + { data: message, from: address }, + ); + machine = transitionWalletRegistration(machine, { type: 'SIGN_OK' }); + } catch (error) { + machine = transitionWalletRegistration(machine, { + type: 'SIGN_FAILED', + retryable: false, + }); + throw error; + } + + try { + const result = await this.messenger.call( + 'NeoBankService:registerSelfHostedWallet', + { + address, + customerId, + message, + signature, + idempotencyKey, + }, + ); + machine = transitionWalletRegistration(machine, { type: 'SUBMIT_OK' }); + return result; + } catch (error) { + if (!(error instanceof WalletRegistrationError)) { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + throw error; + } + + if (error.kind === 'conflict') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_CONFLICT', + }); + } else if (error.kind === 'transient') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TRANSIENT', + }); + } else if (error.kind === 'validation') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_VALIDATION', + utcRollover: + buildOwnershipMessage({ + address, + customerId, + now: new Date(), + }) !== message, + }); + } else if (error.kind === 'rateLimited') { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_RATE_LIMITED', + }); + } else { + machine = transitionWalletRegistration(machine, { + type: 'SUBMIT_TERMINAL', + }); + } + + if ( + machine.status === 'disambiguate409' || + machine.status === 'checkThenRetry' + ) { + const reconciledResult = applyLookup(await lookup()); + if (reconciledResult) { + return reconciledResult; + } + } + + if (machine.status !== 'signing') { + throw error; + } + } + } + } + /** * Removes a local autoramp account by id. * Soft-deletes the remote User Storage entry when sync is available. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index d8f3f827c52..b897a34331e 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -12,6 +12,9 @@ export type { ResourceState, TransakState, NativeProvidersState, + MoneyAccountWalletRegistrationResult, + KycControllerGetCustomerIdentityAction, + KeyringControllerSignPersonalMessageAction, } from './RampsController.js'; export type { RampsControllerExecuteRequestAction, @@ -33,6 +36,7 @@ export type { RampsControllerAddAutorampAction, RampsControllerCreateAutorampAction, RampsControllerRemoveAutorampAction, + RampsControllerRegisterMoneyAccountWalletAction, RampsControllerMarkAutorampAsNotifiedAction, RampsControllerApplyAutorampStatusFromPushAction, RampsControllerRefreshAutorampAction, @@ -216,6 +220,8 @@ export type { NeoBankAutorampResponse, NeoBankRequestOptions, NeoBankQueryParams, + GetWalletRegistrationStatusParams, + RegisterSelfHostedWalletParams, } from './NeoBankService.js'; export type { NeoBankServiceGetAutorampAction, @@ -225,6 +231,9 @@ export type { NeoBankServiceGetAutorampQuoteForAutorampAction, NeoBankServiceAttachAutorampQuoteAction, NeoBankServiceGetCustomerByExternalIdAction, + NeoBankServiceGetMoonpayCustomerIdAction, + NeoBankServiceGetWalletRegistrationStatusAction, + NeoBankServiceRegisterSelfHostedWalletAction, NeoBankServiceMethodActions, } from './NeoBankService-method-action-types.js'; export { @@ -287,3 +296,13 @@ export type { TransakServiceGeneratePaymentWidgetUrlAction, TransakServiceCreateWidgetUrlAction, } from './TransakService-method-action-types.js'; + +export type { + Blockchain, + RegistrationOutcome, + RegistrationStatus, + SelfHostedRegistration, + WalletRegistrationErrorKind, +} from './wallet-registration-service.js'; +export { WalletRegistrationError } from './wallet-registration-service.js'; +export { buildOwnershipMessage } from './ownership-message.js'; diff --git a/packages/kyc-controller/src/ownership-message.test.ts b/packages/ramps-controller/src/ownership-message.test.ts similarity index 100% rename from packages/kyc-controller/src/ownership-message.test.ts rename to packages/ramps-controller/src/ownership-message.test.ts diff --git a/packages/kyc-controller/src/ownership-message.ts b/packages/ramps-controller/src/ownership-message.ts similarity index 100% rename from packages/kyc-controller/src/ownership-message.ts rename to packages/ramps-controller/src/ownership-message.ts diff --git a/packages/kyc-controller/src/wallet-registration-machine.test.ts b/packages/ramps-controller/src/wallet-registration-machine.test.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-machine.test.ts rename to packages/ramps-controller/src/wallet-registration-machine.test.ts diff --git a/packages/kyc-controller/src/wallet-registration-machine.ts b/packages/ramps-controller/src/wallet-registration-machine.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-machine.ts rename to packages/ramps-controller/src/wallet-registration-machine.ts diff --git a/packages/kyc-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-service.test.ts rename to packages/ramps-controller/src/wallet-registration-service.test.ts diff --git a/packages/kyc-controller/src/wallet-registration-service.ts b/packages/ramps-controller/src/wallet-registration-service.ts similarity index 100% rename from packages/kyc-controller/src/wallet-registration-service.ts rename to packages/ramps-controller/src/wallet-registration-service.ts diff --git a/packages/ramps-controller/tsconfig.build.json b/packages/ramps-controller/tsconfig.build.json index 1c325de88b8..c7f4c2add68 100644 --- a/packages/ramps-controller/tsconfig.build.json +++ b/packages/ramps-controller/tsconfig.build.json @@ -21,9 +21,6 @@ }, { "path": "../remote-feature-flag-controller/tsconfig.build.json" - }, - { - "path": "../kyc-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/ramps-controller/tsconfig.json b/packages/ramps-controller/tsconfig.json index edb3ae546cb..f85e8ef6394 100644 --- a/packages/ramps-controller/tsconfig.json +++ b/packages/ramps-controller/tsconfig.json @@ -19,9 +19,6 @@ }, { "path": "../controller-utils" - }, - { - "path": "../kyc-controller" } ], "include": ["../../types", "./src"] diff --git a/yarn.lock b/yarn.lock index 6e50507ac38..467ee181753 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7679,7 +7679,7 @@ __metadata: languageName: node linkType: hard -"@metamask/kyc-controller@npm:^0.0.0, @metamask/kyc-controller@workspace:packages/kyc-controller": +"@metamask/kyc-controller@workspace:packages/kyc-controller": version: 0.0.0-use.local resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" dependencies: @@ -7688,7 +7688,6 @@ __metadata: "@metamask/base-data-service": "npm:^0.1.3" "@metamask/controller-utils": "npm:^12.3.0" "@metamask/geolocation-controller": "npm:^1.0.0" - "@metamask/keyring-controller": "npm:^27.1.1" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/superstruct": "npm:^3.4.1" @@ -8688,7 +8687,6 @@ __metadata: "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.3.0" - "@metamask/kyc-controller": "npm:^0.0.0" "@metamask/messenger": "npm:^2.0.0" "@metamask/profile-sync-controller": "npm:^29.0.0" "@metamask/remote-feature-flag-controller": "npm:^5.0.0" From 8667a18c3b924df2ca4948a6424736b6a5567e44 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 01:37:24 -0600 Subject: [PATCH 64/67] refactor(ramps): stop resolving autoramp customer id via KycController (#9859) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - `RampsController.resolveAutorampCustomerId()` no longer calls `KycController:getCustomerIdentity`. Autoramp / Money Account wallet registration always resolve the MoonPay customer id via Profile Sync (`AuthenticationController:getSessionProfile`) → `NeoBankService:getCustomerByExternalId`, preferring `canonicalProfileId` then `profileId` (same pattern as `NeoBankService.#getCanonicalExternalId`). - Removed the local `KycControllerGetCustomerIdentityAction` type, its `AllowedActions` union entry, index export, and the action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. - Updated createAutoramp / registerMoneyAccountWallet tests and CHANGELOG Unreleased. ## Residual risk Iron / MoonPay customers must already be mapped by external id for `createAutoramp` and `registerMoneyAccountWallet` to succeed. There is no longer a KYC-session identity shortcut. ## Test plan - [x] `yarn workspace @metamask/ramps-controller test --coverage=false --reporters=default` → 18 suites / 843+ tests passed - [x] Focused createAutoramp / registerMoneyAccountWallet cases pass - [ ] Host apps: confirm they no longer need to delegate `KycController:getCustomerIdentity` to the RampsController messenger for autoramp / wallet registration - [ ] Manual: autoramp create + Money Account wallet registration with a profile that has a neo-bank external-id mapping Made with [Cursor](https://cursor.com) Co-authored-by: Cursor --- packages/ramps-controller/CHANGELOG.md | 8 +- .../RampsController-method-action-types.ts | 14 +- .../src/RampsController.test.ts | 172 +++++++++++++++--- .../ramps-controller/src/RampsController.ts | 58 +++--- packages/ramps-controller/src/index.ts | 1 - 5 files changed, 176 insertions(+), 77 deletions(-) diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 324e864aa1a..be4d7e33244 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,17 +9,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from the KYC controller via the new `KycController:getCustomerIdentity` action, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when no verified KYC identity is available. -- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`KycController:getCustomerIdentity`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation. +- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from Profile Sync (`AuthenticationController:getSessionProfile`) via `NeoBankService:getCustomerByExternalId`, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when the wallet is not signed in or no MoonPay customer is mapped to the external id. +- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation and Money Account wallet registration. - Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. - Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) -- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id (KYC session identity when available, otherwise the neobank-proxy external-id lookup), signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) +- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id via Profile Sync → neobank-proxy external-id lookup, signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) - Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. - Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ### Changed -- Resolve the vendor customer identity for autoramps via a locally declared `KycController:getCustomerIdentity` messenger action type instead of a package dependency on `@metamask/kyc-controller`, keeping the ramps↔kyc packages decoupled in the monorepo build. +- Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ## [20.0.0] diff --git a/packages/ramps-controller/src/RampsController-method-action-types.ts b/packages/ramps-controller/src/RampsController-method-action-types.ts index d15a6ea8898..b0ce940b265 100644 --- a/packages/ramps-controller/src/RampsController-method-action-types.ts +++ b/packages/ramps-controller/src/RampsController-method-action-types.ts @@ -299,8 +299,8 @@ export type RampsControllerAddAutorampAction = { * * The MoonPay `customer_id` is not accepted from callers: it is resolved via * {@link RampsController.resolveAutorampCustomerId} and injected into the - * request. This keeps the sensitive customer id owned by KYC / the neo-bank - * proxy and avoids requiring the UI to know or plumb it. + * request. This keeps the sensitive customer id owned by Profile Sync / + * the neo-bank proxy and avoids requiring the UI to know or plumb it. * * @param request - CreateAutoramp payload (any `customer_id` is overwritten). * @param options - Optional idempotency key forwarded to the proxy. @@ -316,11 +316,11 @@ export type RampsControllerCreateAutorampAction = { * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller resolves the Iron - * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC - * session identity when available, otherwise the neobank-proxy external-id - * lookup) before the first list/lookup because list requires `customer_id` - * in the path. Message construction, EIP-191 signing, submission, and - * ambiguous-write reconciliation stay internal to this controller. + * customer id via {@link RampsController.resolveAutorampCustomerId} + * (Profile Sync → neobank-proxy external-id lookup) before the first + * list/lookup because list requires `customer_id` in the path. Message + * construction, EIP-191 signing, submission, and ambiguous-write + * reconciliation stay internal to this controller. * * @param params - Money Account wallet registration parameters. * @param params.address - Monad Money Account address. diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 92843826224..9c304dcddfb 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9033,11 +9033,21 @@ describe('RampsController', () => { }); }); - it('injects the KYC customer id and applies the created autoramp', async () => { + it('injects the Profile Sync customer id and applies the created autoramp', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( - 'KycController:getCustomerIdentity', - () => ({ vendor: 'moonpay', id: 'cust-99' }), + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + async () => ({ id: 'cust-99' }), ); const createAutoramp = jest.fn().mockResolvedValue({ id: 'ar-new', @@ -9066,12 +9076,44 @@ describe('RampsController', () => { }); }); - it('throws when no KYC identity or mapped external customer is available', async () => { + it('prefers canonicalProfileId when resolving the external customer id', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( - 'KycController:getCustomerIdentity', - () => null, + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-canonical' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn().mockResolvedValue({ + id: 'ar-new', + customerId: 'cust-canonical', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('canonical-1'); + }); + }); + + it('throws when no mapped external customer is available', async () => { + await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( 'AuthenticationController:getSessionProfile', async () => @@ -9098,6 +9140,72 @@ describe('RampsController', () => { }); }); + it('throws when the wallet is not signed in to Profile Sync', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: '', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + const createAutoramp = jest.fn(); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + createAutoramp, + ); + + await expect(controller.createAutoramp({})).rejects.toThrow( + /wallet is not signed in to Profile Sync/u, + ); + expect(getCustomerByExternalId).not.toHaveBeenCalled(); + expect(createAutoramp).not.toHaveBeenCalled(); + }); + }); + + it('falls back to profileId when canonicalProfileId is empty', async () => { + await withController(async ({ controller, rootMessenger }) => { + rootMessenger.registerActionHandler( + 'AuthenticationController:getSessionProfile', + async () => + ({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: '', + metaMetricsId: 'mm-1', + }) as never, + ); + const getCustomerByExternalId = jest + .fn() + .mockResolvedValue({ id: 'cust-profile' }); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + getCustomerByExternalId, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:createAutoramp', + async () => ({ + id: 'ar-new', + customerId: 'cust-profile', + walletAddress: '0xabc', + status: AutorampStatus.Created, + }), + ); + + await controller.createAutoramp({}); + + expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + }); + }); + it('skips failed refreshes when refreshing all autoramps', async () => { await withController(async ({ controller, rootMessenger }) => { rootMessenger.registerActionHandler( @@ -9209,7 +9317,8 @@ describe('RampsController', () => { }; type WalletRegistrationHandlers = { - getCustomerIdentity: jest.Mock; + getSessionProfile: jest.Mock; + getCustomerByExternalId: jest.Mock; getWalletRegistrationStatus: jest.Mock; registerSelfHostedWallet: jest.Mock; signPersonalMessage: jest.Mock; @@ -9226,9 +9335,14 @@ describe('RampsController', () => { rootMessenger: RootMessenger, ): WalletRegistrationHandlers { const handlers: WalletRegistrationHandlers = { - getCustomerIdentity: jest + getSessionProfile: jest.fn().mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + metaMetricsId: 'mm-1', + }), + getCustomerByExternalId: jest .fn() - .mockReturnValue({ vendor: 'iron', id: 'iron-customer-1' }), + .mockResolvedValue({ id: 'iron-customer-1' }), getWalletRegistrationStatus: jest .fn() .mockResolvedValue({ type: 'absent' }), @@ -9239,8 +9353,12 @@ describe('RampsController', () => { signPersonalMessage: jest.fn().mockResolvedValue('0xsig'), }; rootMessenger.registerActionHandler( - 'KycController:getCustomerIdentity', - handlers.getCustomerIdentity, + 'AuthenticationController:getSessionProfile', + handlers.getSessionProfile, + ); + rootMessenger.registerActionHandler( + 'NeoBankService:getCustomerByExternalId', + handlers.getCustomerByExternalId, ); rootMessenger.registerActionHandler( 'NeoBankService:getWalletRegistrationStatus', @@ -9317,30 +9435,24 @@ describe('RampsController', () => { }); }); - it('falls back to the external-id customer lookup when KYC has no identity', async () => { + it('resolves the customer id via Profile Sync external-id lookup', async () => { await withController(async ({ controller, rootMessenger }) => { const handlers = registerWalletRegistrationHandlers(rootMessenger); - handlers.getCustomerIdentity.mockReturnValue(null); - rootMessenger.registerActionHandler( - 'AuthenticationController:getSessionProfile', - async () => - ({ - identifierId: 'id-1', - profileId: 'profile-1', - metaMetricsId: 'mm-1', - }) as never, - ); - const getCustomerByExternalId = jest - .fn() - .mockResolvedValue({ id: 'iron-customer-fallback' }); - rootMessenger.registerActionHandler( - 'NeoBankService:getCustomerByExternalId', - getCustomerByExternalId, - ); + handlers.getSessionProfile.mockResolvedValue({ + identifierId: 'id-1', + profileId: 'profile-1', + canonicalProfileId: 'canonical-1', + metaMetricsId: 'mm-1', + }); + handlers.getCustomerByExternalId.mockResolvedValue({ + id: 'iron-customer-fallback', + }); await controller.registerMoneyAccountWallet({ address: '0xabc' }); - expect(getCustomerByExternalId).toHaveBeenCalledWith('profile-1'); + expect(handlers.getCustomerByExternalId).toHaveBeenCalledWith( + 'canonical-1', + ); expect(handlers.getWalletRegistrationStatus).toHaveBeenCalledWith({ customerId: 'iron-customer-fallback', address: '0xabc', diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 40e8d1b37d9..1187bd17fcc 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -221,27 +221,17 @@ export const RAMPS_CONTROLLER_REQUIRED_SERVICE_ACTIONS = [ /** * Other controller actions RampsController calls via the messenger. * Hosts that enable autoramp creation must delegate these from the root - * messenger so the controller can resolve the vendor customer identity. - * `KeyringController:signPersonalMessage` is required for Money Account - * self-hosted wallet registration (EIP-191 ownership proof). + * messenger so the controller can resolve the vendor customer identity via + * Profile Sync (`AuthenticationController:getSessionProfile`) and the + * neo-bank external-id lookup. `KeyringController:signPersonalMessage` is + * required for Money Account self-hosted wallet registration (EIP-191 + * ownership proof). */ export const RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS = [ - 'KycController:getCustomerIdentity', 'AuthenticationController:getSessionProfile', 'KeyringController:signPersonalMessage', ] as const; -/** - * Structural type for the KYC controller's `getCustomerIdentity` messenger - * action. Declared locally (mirroring `@metamask/kyc-controller`) so this - * package does not need a dependency on the KYC package; the messenger only - * matches on the action `type` string, so the shapes stay compatible. - */ -export type KycControllerGetCustomerIdentityAction = { - type: 'KycController:getCustomerIdentity'; - handler: () => { vendor: string; id: string } | null; -}; - /** * Structural type for the keyring controller's `signPersonalMessage` messenger * action (EIP-191). Declared locally (mirroring @@ -762,7 +752,6 @@ type AllowedActions = | NeoBankServiceGetCustomerByExternalIdAction | NeoBankServiceGetWalletRegistrationStatusAction | NeoBankServiceRegisterSelfHostedWalletAction - | KycControllerGetCustomerIdentityAction | KeyringControllerSignPersonalMessageAction | UserStorageController.UserStorageControllerGetStateAction | UserStorageController.UserStorageControllerPerformGetStorageAllFeatureEntriesAction @@ -2718,8 +2707,8 @@ export class RampsController extends BaseController< * * The MoonPay `customer_id` is not accepted from callers: it is resolved via * {@link RampsController.resolveAutorampCustomerId} and injected into the - * request. This keeps the sensitive customer id owned by KYC / the neo-bank - * proxy and avoids requiring the UI to know or plumb it. + * request. This keeps the sensitive customer id owned by Profile Sync / + * the neo-bank proxy and avoids requiring the UI to know or plumb it. * * @param request - CreateAutoramp payload (any `customer_id` is overwritten). * @param options - Optional idempotency key forwarded to the proxy. @@ -2744,25 +2733,24 @@ export class RampsController extends BaseController< /** * Resolves the MoonPay `customer_id` for autoramp operations. * - * Prefers the KYC controller's session-scoped identity (populated when a - * MoonPay Check/Auth frame reports a `customer.id`). When that is not yet - * available, falls back to mapping the wallet's Profile Sync id (the partner - * `external_id`) to the MoonPay customer via the neo-bank proxy's - * `GET /neobank/customers/{external_id}/external`. + * Maps the wallet's Profile Sync id (the partner `external_id`) to the + * MoonPay customer via the neo-bank proxy's + * `GET /neobank/customers/{external_id}/external`. Prefers + * `canonicalProfileId` when present, otherwise `profileId`, matching + * {@link NeoBankService}'s canonical external-id resolution. * * @returns The MoonPay customer id. */ async resolveAutorampCustomerId(): Promise { - const identity = this.messenger.call('KycController:getCustomerIdentity'); - if (identity?.id) { - return identity.id; - } - const profile = await this.messenger.call( 'AuthenticationController:getSessionProfile', ); - const externalId = profile?.profileId; - if (!externalId) { + const canonical = profile?.canonicalProfileId; + const externalId = + typeof canonical === 'string' && canonical.length > 0 + ? canonical + : profile?.profileId; + if (typeof externalId !== 'string' || externalId.length === 0) { throw new Error( 'Cannot create autoramp: wallet is not signed in to Profile Sync.', ); @@ -2790,11 +2778,11 @@ export class RampsController extends BaseController< * Registers a Money Account wallet with MoonPay Iron via neobank-proxy. * * Consumers provide only the Monad address. The controller resolves the Iron - * customer id via {@link RampsController.resolveAutorampCustomerId} (KYC - * session identity when available, otherwise the neobank-proxy external-id - * lookup) before the first list/lookup because list requires `customer_id` - * in the path. Message construction, EIP-191 signing, submission, and - * ambiguous-write reconciliation stay internal to this controller. + * customer id via {@link RampsController.resolveAutorampCustomerId} + * (Profile Sync → neobank-proxy external-id lookup) before the first + * list/lookup because list requires `customer_id` in the path. Message + * construction, EIP-191 signing, submission, and ambiguous-write + * reconciliation stay internal to this controller. * * @param params - Money Account wallet registration parameters. * @param params.address - Monad Money Account address. diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index b897a34331e..7912e635237 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -13,7 +13,6 @@ export type { TransakState, NativeProvidersState, MoneyAccountWalletRegistrationResult, - KycControllerGetCustomerIdentityAction, KeyringControllerSignPersonalMessageAction, } from './RampsController.js'; export type { From c6b9443998e65080f0e73f2cceb02a65f753f4e8 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 01:43:44 -0600 Subject: [PATCH 65/67] fix: unblock neobank-demo lint for #9853 (#9860) ## Summary - Fix eslint/oxfmt issues blocking lint on the neobank-demo stack (NeoBankService naming, test titles, return types, JSDoc, MethodActions export) - Add `#9853` Unreleased changelog PR links for kyc/ramps/profile-sync/transaction-pay - Fix transaction-pay jest `resolves` matchers restricted by eslint ## Test plan - [x] `yarn eslint` on ramps-controller + fixed transaction-pay test - [x] `yarn lint:misc --check` on touched packages - [x] `yarn workspace @metamask/ramps-controller messenger-action-types:check` - [ ] CI lint jobs on this PR / #9853 after merge Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor --- packages/kyc-controller/CHANGELOG.md | 10 +-- packages/profile-sync-controller/CHANGELOG.md | 4 +- packages/ramps-controller/CHANGELOG.md | 18 ++--- .../src/NeoBankService.test.ts | 26 ++++--- .../ramps-controller/src/NeoBankService.ts | 23 ++++-- .../src/RampsController.test.ts | 4 +- .../ramps-controller/src/RampsController.ts | 77 ++++++++++--------- .../controller-integration.ts | 25 +++--- .../src/autoramp-syncing/format-utils.test.ts | 5 +- .../src/autoramp-syncing/format-utils.ts | 20 ++--- .../src/autoramp-syncing/sync-utils.test.ts | 4 +- .../src/autoramp-syncing/sync-utils.ts | 3 +- .../ramps-controller/src/autorampAccount.ts | 15 +++- packages/ramps-controller/src/index.ts | 1 - .../transaction-pay-controller/CHANGELOG.md | 14 ++-- .../src/TransactionPayController.test.ts | 24 +++--- .../src/utils/chomp.test.ts | 9 +-- 17 files changed, 146 insertions(+), 136 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index c661ce10e1b..0c857a9d1a2 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. -- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852)) -- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781)) -- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) +- Add `KycController.getCustomerIdentity()` method and the `KycController:getCustomerIdentity` messenger action (plus the exported `KycControllerGetCustomerIdentityAction` and `KycCustomerIdentity` types). Returns the vendor-scoped `{ vendor, id }` for the currently authenticated customer, or `null` before authentication and after `reset()`. Lets consumers (e.g. ramps autoramp creation) attach the vendor customer id to downstream calls without reading the full KYC state, which also holds session/access tokens. The id is session-scoped and never persisted. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add Iron (Money/VBA) KYC path to `@metamask/kyc-controller`: `vendor: 'iron'` skips MoonPay Check/Auth frames; `KycService` clients for `/vendors/iron/*`, `POST /consents`, and `GET /kyc/status`; `refreshKycStatus` + `statusChanged` for Money toast state ([#9852](https://github.com/MetaMask/core/pull/9852), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Initial release of the `@metamask/kyc-controller` package for managing KYC / identity verification state across MetaMask clients ([#9781](https://github.com/MetaMask/core/pull/9781), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `KycController` and `KycService` for managing KYC / identity verification state across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615), [#9853](https://github.com/MetaMask/core/pull/9853)) - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. - `KycService` extends `BaseDataService` and performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. @@ -23,6 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed -- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. +- Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. ([#9853](https://github.com/MetaMask/core/pull/9853)) [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/profile-sync-controller/CHANGELOG.md b/packages/profile-sync-controller/CHANGELOG.md index 9b27e131240..88d0c936d49 100644 --- a/packages/profile-sync-controller/CHANGELOG.md +++ b/packages/profile-sync-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794)) -- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791)) +- Resolve HD entropy source IDs from `KeyringController` instead of the message-signing snap (`getBearerToken` primary ID, `performSignIn` SRP enumeration) ([#9794](https://github.com/MetaMask/core/pull/9794), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Bump `@metamask/keyring-controller` from `^27.1.0` to `^27.1.1` ([#9791](https://github.com/MetaMask/core/pull/9791), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [29.0.0] diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index be4d7e33244..34d66549c04 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -9,18 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from Profile Sync (`AuthenticationController:getSessionProfile`) via `NeoBankService:getCustomerByExternalId`, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when the wallet is not signed in or no MoonPay customer is mapped to the external id. -- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation and Money Account wallet registration. -- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. -- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679)) -- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id via Profile Sync → neobank-proxy external-id lookup, signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847)) -- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. -- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). +- Add `RampsController.createAutoramp(request, options?)` method and the `RampsController:createAutoramp` messenger action (plus the exported `RampsControllerCreateAutorampAction` and `CreateAutorampRequest` types). It resolves the MoonPay `customer_id` from Profile Sync (`AuthenticationController:getSessionProfile`) via `NeoBankService:getCustomerByExternalId`, injects it into the request (overwriting any caller-supplied `customer_id`), forwards the body to `NeoBankService:createAutoramp`, and applies the returned snapshot to local state. Throws when the wallet is not signed in or no MoonPay customer is mapped to the external id. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add the exported `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS` constant listing the other-controller actions (`AuthenticationController:getSessionProfile`, `KeyringController:signPersonalMessage`) that hosts must delegate to the `RampsController` messenger to enable autoramp creation and Money Account wallet registration. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Add NeoBankService Pix / autoramp quote client methods and messenger actions, targeting the neobank-proxy `/neobank` prefix on the Ramp API host: `registerPixAddress`, `getAutorampQuote`, `createAutoramp`, `getAutorampQuoteForAutoramp`, `attachAutorampQuote`, and `getCustomerByExternalId`. Pix/quote helpers return parsed proxy JSON; `createAutoramp` maps autoramp-shaped responses via `mapNeoBankAutorampToRemoteSnapshot` (same as `getAutoramp`). Optional `Idempotency-Key` is supported on mutating calls. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Export `TERMINAL_ORDER_STATUSES` and `isTerminalOrderStatus()` so consuming clients can share the controller's terminal order status set instead of maintaining duplicate copies. ([#9679](https://github.com/MetaMask/core/pull/9679), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `RampsController.registerMoneyAccountWallet({ address })` method and the `RampsController:registerMoneyAccountWallet` messenger action (moved from `@metamask/kyc-controller`). Resolves the MoonPay Iron customer id via Profile Sync → neobank-proxy external-id lookup, signs the Monad ownership message via `KeyringController:signPersonalMessage`, and registers the self-hosted wallet through the neobank-proxy — including `409` disambiguation, transient-failure reconciliation, and UTC date rollover re-signing ([#9850](https://github.com/MetaMask/core/pull/9850), [#9847](https://github.com/MetaMask/core/pull/9847), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `NeoBankService.getMoonpayCustomerId`, `NeoBankService.getWalletRegistrationStatus`, and `NeoBankService.registerSelfHostedWallet` methods and messenger actions, targeting the transparent neobank routes (`GET /neobank/customers/{external_id}/external`, `GET /neobank/addresses/crypto/{customer_id}`, `POST /neobank/addresses/crypto/selfhosted`) with client-side Monad filtering, `Idempotency-Key` support, and upstream error bodies mirrored 1:1. ([#9853](https://github.com/MetaMask/core/pull/9853)) +- Export the wallet registration types (`SelfHostedRegistration`, `RegistrationStatus`, `RegistrationOutcome`, `WalletRegistrationError`, `WalletRegistrationErrorKind`, `MoneyAccountWalletRegistrationResult`) and `buildOwnershipMessage` (moved from `@metamask/kyc-controller`). ([#9853](https://github.com/MetaMask/core/pull/9853)) ### Changed -- Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. -- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. +- Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. ([#9859](https://github.com/MetaMask/core/pull/9859), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ([#9853](https://github.com/MetaMask/core/pull/9853)) ## [20.0.0] diff --git a/packages/ramps-controller/src/NeoBankService.test.ts b/packages/ramps-controller/src/NeoBankService.test.ts index e30812e3460..94cc82f2f05 100644 --- a/packages/ramps-controller/src/NeoBankService.test.ts +++ b/packages/ramps-controller/src/NeoBankService.test.ts @@ -1,4 +1,6 @@ -import nock from 'nock'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { MockAnyNamespace } from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; import { mapNeoBankAutorampToRemoteSnapshot, @@ -6,8 +8,6 @@ import { } from './NeoBankService.js'; import type { NeoBankServiceMessenger } from './NeoBankService.js'; import { RampsEnvironment } from './RampsService.js'; -import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; -import type { MockAnyNamespace } from '@metamask/messenger'; const STAGING_BASE = 'https://on-ramp.uat-api.cx.metamask.io'; @@ -82,7 +82,7 @@ function createService(options?: { describe('NeoBankService', () => { afterEach(() => { - nock.cleanAll(); + cleanAll(); }); describe('mapNeoBankAutorampToRemoteSnapshot', () => { @@ -132,7 +132,7 @@ describe('NeoBankService', () => { }); describe('getAutoramp', () => { - it('GETs /neobank/autoramps/{id} with bearer auth', async () => { + it('gets /neobank/autoramps/{id} with bearer auth', async () => { const scope = nock(STAGING_BASE) .get(/\/neobank\/autoramps\/ar-1/u) .matchHeader('Authorization', 'Bearer test-token') @@ -156,7 +156,9 @@ describe('NeoBankService', () => { }); it('throws HttpError when the proxy returns a non-2xx status', async () => { - nock(STAGING_BASE).get(/\/neobank\/autoramps\/missing/u).reply(404); + nock(STAGING_BASE) + .get(/\/neobank\/autoramps\/missing/u) + .reply(404); const service = createService(); await expect(service.getAutoramp('missing')).rejects.toThrow( @@ -177,7 +179,7 @@ describe('NeoBankService', () => { }); describe('registerPixAddress', () => { - it('POSTs /neobank/addresses/pix with JSON body and bearer auth', async () => { + it('posts /neobank/addresses/pix with JSON body and bearer auth', async () => { const body = { type: 'Pix', pix_key: 'user@example.com', @@ -216,7 +218,7 @@ describe('NeoBankService', () => { }); describe('getAutorampQuote', () => { - it('GETs /neobank/autoramps/quote with query params', async () => { + it('gets /neobank/autoramps/quote with query params', async () => { const scope = nock(STAGING_BASE) .get('/neobank/autoramps/quote') .query((query) => { @@ -243,7 +245,7 @@ describe('NeoBankService', () => { }); describe('createAutoramp', () => { - it('POSTs /neobank/autoramps and maps the Autoramp response', async () => { + it('posts /neobank/autoramps and maps the Autoramp response', async () => { const body = { signed_quote: 'sig', customer_id: 'cust-1', @@ -309,7 +311,7 @@ describe('NeoBankService', () => { }); describe('getAutorampQuoteForAutoramp', () => { - it('GETs /neobank/autoramps/{id}/quote with query params', async () => { + it('gets /neobank/autoramps/{id}/quote with query params', async () => { const scope = nock(STAGING_BASE) .get('/neobank/autoramps/ar-1/quote') .query((query) => { @@ -329,7 +331,7 @@ describe('NeoBankService', () => { }); describe('attachAutorampQuote', () => { - it('POSTs /neobank/autoramps/{id}/quotes with JSON body', async () => { + it('posts /neobank/autoramps/{id}/quotes with JSON body', async () => { const body = { signed_quote: 'attach-sig' }; const scope = nock(STAGING_BASE) @@ -348,7 +350,7 @@ describe('NeoBankService', () => { }); describe('getCustomerByExternalId', () => { - it('GETs /neobank/customers/{external_id}/external', async () => { + it('gets /neobank/customers/{external_id}/external', async () => { const scope = nock(STAGING_BASE) .get('/neobank/customers/ext-1/external') .query(true) diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index 832a7ee74a6..cbc358c03f2 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -32,16 +32,20 @@ export const serviceName = 'NeoBankService'; */ export type NeoBankAutorampResponse = { id: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field customer_id: string; status: string; /** * Destination wallet when present on the proxy response. * Field name may evolve with the Ramp API contract. */ + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field wallet_address?: string; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field recipient_account?: { address?: string; }; + // eslint-disable-next-line @typescript-eslint/naming-convention -- MoonPay API field deposit_rails?: unknown[]; }; @@ -162,7 +166,8 @@ export function mapNeoBankAutorampToRemoteSnapshot( response: NeoBankAutorampResponse, ): AutorampRemoteSnapshot { const depositRails = response.deposit_rails; - const hasDepositRails = Array.isArray(depositRails) && depositRails.length > 0; + const hasDepositRails = + Array.isArray(depositRails) && depositRails.length > 0; const depositRailsSummary: AutorampDepositRailsSummary | undefined = hasDepositRails || response.status === 'Approved' ? { @@ -291,10 +296,10 @@ export class NeoBankService { return url; } - async #getJson( + async #getJson( path: string, query?: NeoBankQueryParams, - ): Promise { + ): Promise { const url = this.#buildUrl(path, query); return this.#policy.execute(async () => { const headers = await this.#getRequestHeaders(); @@ -305,15 +310,15 @@ export class NeoBankService { `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, ); } - return fetchResponse.json() as Promise; + return fetchResponse.json() as Promise; }); } - async #postJson( + async #postJson( path: string, body: Record, options: NeoBankRequestOptions, - ): Promise { + ): Promise { const url = this.#buildUrl(path); return this.#policy.execute(async () => { const headers = await this.#getRequestHeaders(options); @@ -329,11 +334,13 @@ export class NeoBankService { `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, ); } - return fetchResponse.json() as Promise; + return fetchResponse.json() as Promise; }); } - #mapAutorampResponse(response: NeoBankAutorampResponse): AutorampRemoteSnapshot { + #mapAutorampResponse( + response: NeoBankAutorampResponse, + ): AutorampRemoteSnapshot { if (!response || typeof response !== 'object' || !response.id) { throw new Error('Malformed response received from neo-bank autoramp API'); } diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index 9c304dcddfb..e6f3b43aaa8 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -11,8 +11,8 @@ import type { Json } from '@metamask/utils'; import * as fs from 'fs'; import * as path from 'path'; -import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; import { AutorampStatus } from './autorampAccount.js'; +import { MONEY_HEADLESS_ALL_PROVIDERS_FLAG_KEY } from './featureFlags.js'; import type { RampsControllerMessenger, RampsControllerState, @@ -50,7 +50,6 @@ import type { } from './RampsService.js'; import { RampsOrderStatus } from './RampsService.js'; import { RequestStatus } from './RequestCache.js'; -import { WalletRegistrationError } from './wallet-registration-service.js'; import type { TransakAccessToken, TransakUserDetails, @@ -67,6 +66,7 @@ import type { TransakOrderPaymentMethod, PatchUserRequestBody, } from './TransakService.js'; +import { WalletRegistrationError } from './wallet-registration-service.js'; /** * The default redirect ("fake callback") URL a staging `RampsService` returns. diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index 1187bd17fcc..e691bbf6233 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -6,16 +6,22 @@ import type { import { BaseController } from '@metamask/base-controller'; import { BrokenCircuitError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import type { UserStorageController } from '@metamask/profile-sync-controller'; import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { Json } from '@metamask/utils'; import type { Draft } from 'immer'; import { - getHeadlessProviderAllowlist, - isHeadlessAllProvidersEnabled, - normalizeHeadlessProviderId, -} from './featureFlags.js'; - + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal, + updateAutorampInRemoteStorage, +} from './autoramp-syncing/index.js'; +import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; +import type { + AutorampSyncingController, + AutorampSyncingOptions, +} from './autoramp-syncing/types.js'; import type { AutorampAccount, AutorampRemoteSnapshot, @@ -27,11 +33,10 @@ import { markAutorampNotified, } from './autorampAccount.js'; import { - deleteAutorampInRemoteStorage, - syncAutorampsWithUserStorage as syncAutorampsWithUserStorageInternal, - updateAutorampInRemoteStorage, -} from './autoramp-syncing/index.js'; -import type { SyncAutorampsWithUserStorageConfig } from './autoramp-syncing/index.js'; + getHeadlessProviderAllowlist, + isHeadlessAllProvidersEnabled, + normalizeHeadlessProviderId, +} from './featureFlags.js'; import type { NeoBankServiceCreateAutorampAction, NeoBankServiceGetAutorampAction, @@ -40,25 +45,11 @@ import type { NeoBankServiceRegisterSelfHostedWalletAction, } from './NeoBankService-method-action-types.js'; import type { NeoBankServiceActions } from './NeoBankService.js'; -import { buildOwnershipMessage } from './ownership-message.js'; -import { - createInitialState as createInitialWalletRegistrationState, - transition as transitionWalletRegistration, -} from './wallet-registration-machine.js'; -import { - createIdempotencyKey, - WalletRegistrationError, -} from './wallet-registration-service.js'; -import type { - RegistrationStatus, - SelfHostedRegistration, -} from './wallet-registration-service.js'; -import type { AuthenticationController } from '@metamask/profile-sync-controller'; -import type { UserStorageController } from '@metamask/profile-sync-controller'; import { PENDING_ORDER_STATUSES, TERMINAL_ORDER_STATUSES, } from './orderStatus.js'; +import { buildOwnershipMessage } from './ownership-message.js'; import { getProvidersServingAsset, providerServesAsset, @@ -156,6 +147,18 @@ import type { TransakOrder, } from './TransakService.js'; import type { TransakServiceActions } from './TransakService.js'; +import { + createInitialState as createInitialWalletRegistrationState, + transition as transitionWalletRegistration, +} from './wallet-registration-machine.js'; +import { + createIdempotencyKey, + WalletRegistrationError, +} from './wallet-registration-service.js'; +import type { + RegistrationStatus, + SelfHostedRegistration, +} from './wallet-registration-service.js'; // === GENERAL === @@ -2591,6 +2594,8 @@ export class RampsController extends BaseController< /** * Whether a full autoramp User Storage sync is currently running. + * + * @returns True when a full autoramp sync is in progress. */ get isAutorampSyncingInProgress(): boolean { return this.#isAutorampSyncingInProgress; @@ -2640,10 +2645,10 @@ export class RampsController extends BaseController< ); } - #getAutorampSyncingOptions() { + #getAutorampSyncingOptions(): AutorampSyncingOptions { return { - getRampsControllerInstance: () => this, - getMessenger: () => this.messenger, + getRampsControllerInstance: (): AutorampSyncingController => this, + getMessenger: (): RampsControllerMessenger => this.messenger, }; } @@ -2666,8 +2671,9 @@ export class RampsController extends BaseController< }, ): AutorampAccount { const account = - 'updatedAt' in accountOrInput && 'lastSeenStatus' in accountOrInput - ? (accountOrInput as AutorampAccount) + typeof (accountOrInput as AutorampAccount).updatedAt === 'number' && + (accountOrInput as AutorampAccount).lastSeenStatus !== undefined + ? accountOrInput : createAutorampAccount(accountOrInput); this.update((state) => { @@ -3022,9 +3028,7 @@ export class RampsController extends BaseController< * @param remote - Remote autoramp snapshot. * @returns The updated local account. */ - applyAutorampStatusFromPush( - remote: AutorampRemoteSnapshot, - ): AutorampAccount { + applyAutorampStatusFromPush(remote: AutorampRemoteSnapshot): AutorampAccount { return this.#applyAutorampRemoteSnapshot(remote); } @@ -3078,9 +3082,12 @@ export class RampsController extends BaseController< ); } - #applyAutorampRemoteSnapshot(remote: AutorampRemoteSnapshot): AutorampAccount { + #applyAutorampRemoteSnapshot( + remote: AutorampRemoteSnapshot, + ): AutorampAccount { const local = - this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? null; + this.state.autoramps.find((autoramp) => autoramp.id === remote.id) ?? + null; const result = applyAutorampRemoteStatus(local, remote); this.update((state) => { diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts index 3834b2c5cf2..28e99d31a82 100644 --- a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -1,3 +1,4 @@ +import type { AutorampAccount } from '../autorampAccount.js'; import { USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, USER_STORAGE_VERSION, @@ -157,7 +158,7 @@ async function saveAutorampsToUserStorage( const { getMessenger, trace } = options; const { onAutorampSyncErroneousSituation } = config; - const save = async () => { + const save = async (): Promise => { const storageEntries: [string, string][] = []; for (const account of accounts) { const key = createAutorampStorageKey(account); @@ -217,13 +218,15 @@ export async function syncAutorampsWithUserStorage( controller.setIsAutorampSyncingInProgress(true); try { - const validRemoteAccounts = (await getRemoteAutoramps(options, config)).filter( + const validRemoteAccounts = ( + await getRemoteAutoramps(options, config) + ).filter( (account: SyncAutorampAccount) => Boolean(account.deletedAt) || isSyncableAutoramp(account), ); - const performSync = async () => { - const getLocalAccounts = () => + const performSync = async (): Promise => { + const getLocalAccounts = (): AutorampAccount[] => controller.state.autoramps.filter(isSyncableAutoramp); const pendingDeleteKeysBeforeApply = new Set( @@ -332,9 +335,8 @@ export async function syncAutorampsWithUserStorage( { name: TraceName.AutorampSyncFull, data: { - localAutorampCount: controller.state.autoramps.filter( - isSyncableAutoramp, - ).length, + localAutorampCount: + controller.state.autoramps.filter(isSyncableAutoramp).length, remoteAutorampCount: validRemoteAccounts.length, }, }, @@ -368,11 +370,8 @@ export async function updateAutorampInRemoteStorage( ): Promise { const { trace } = options; - const update = async () => { - if ( - !canPerformAutorampSyncing(options) || - !isSyncableAutoramp(account) - ) { + const update = async (): Promise => { + if (!canPerformAutorampSyncing(options) || !isSyncableAutoramp(account)) { return; } await saveAutorampsToUserStorage( @@ -403,7 +402,7 @@ export async function deleteAutorampInRemoteStorage( ): Promise { const { trace } = options; - const remove = async () => { + const remove = async (): Promise => { if (!canPerformAutorampSyncing(options) || !account.id) { return; } diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts index 4b00ece4354..540cf7e88cd 100644 --- a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -1,8 +1,5 @@ import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; -import { - USER_STORAGE_VERSION, - USER_STORAGE_VERSION_KEY, -} from './constants.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; import { areAutorampsEqual, createAutorampStorageKey, diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts index 81669481ace..13af41c9249 100644 --- a/packages/ramps-controller/src/autoramp-syncing/format-utils.ts +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.ts @@ -1,13 +1,7 @@ import type { AutorampAccount } from '../autorampAccount.js'; import { normalizeAutorampStatus } from '../autorampAccount.js'; -import { - USER_STORAGE_VERSION, - USER_STORAGE_VERSION_KEY, -} from './constants.js'; -import type { - SyncAutorampAccount, - UserStorageAutorampEntry, -} from './types.js'; +import { USER_STORAGE_VERSION, USER_STORAGE_VERSION_KEY } from './constants.js'; +import type { SyncAutorampAccount, UserStorageAutorampEntry } from './types.js'; /** * Storage key for an autoramp entry (MoonPay autoramp id). @@ -32,11 +26,11 @@ export function isSyncableAutoramp( ): account is AutorampAccount { return Boolean( account && - typeof account.id === 'string' && - account.id.length > 0 && - typeof account.customerId === 'string' && - typeof account.walletAddress === 'string' && - account.status, + typeof account.id === 'string' && + account.id.length > 0 && + typeof account.customerId === 'string' && + typeof account.walletAddress === 'string' && + account.status, ); } diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts index c619447ad23..1b7c83dc45f 100644 --- a/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.test.ts @@ -21,7 +21,7 @@ describe('autoramp-syncing/sync-utils', () => { setIsApplyingAutorampSyncChanges: jest.fn(), addAutoramp: jest.fn(), removeAutoramp: jest.fn(), - getPendingRemoteAutorampDeletes: () => [], + getPendingRemoteAutorampDeletes: (): [] => [], acknowledgePendingRemoteAutorampDeletes: jest.fn(), }), }; @@ -49,7 +49,7 @@ describe('autoramp-syncing/sync-utils', () => { setIsApplyingAutorampSyncChanges: jest.fn(), addAutoramp: jest.fn(), removeAutoramp: jest.fn(), - getPendingRemoteAutorampDeletes: () => [], + getPendingRemoteAutorampDeletes: (): [] => [], acknowledgePendingRemoteAutorampDeletes: jest.fn(), }), } as AutorampSyncingOptions; diff --git a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts index 735bc6f65be..b95a51015f9 100644 --- a/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts +++ b/packages/ramps-controller/src/autoramp-syncing/sync-utils.ts @@ -25,7 +25,8 @@ export function canPerformAutorampSyncing( const isBackupAndSyncEnabled = Boolean( userStorageState.isBackupAndSyncEnabled, ); - const isRampsSyncingEnabled = userStorageState.isRampsSyncingEnabled ?? true; + const isRampsSyncingEnabled = + userStorageState.isRampsSyncingEnabled ?? true; const isAuthEnabled = getMessenger().call( 'AuthenticationController:isSignedIn', ); diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts index b812499a401..03fe6e939a7 100644 --- a/packages/ramps-controller/src/autorampAccount.ts +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -5,6 +5,7 @@ /** * Autoramp lifecycle statuses from MoonPay Enterprise. + * * @see https://dev.enterprise.moonpay.com/autoramp-status */ export enum AutorampStatus { @@ -136,6 +137,12 @@ export function normalizeAutorampStatus( * Build a new local autoramp account from create/response fields. * * @param input - Required identity + status fields. + * @param input.id - MoonPay autoramp id. + * @param input.customerId - MoonPay customer id. + * @param input.walletAddress - Destination wallet address. + * @param input.status - Optional remote status (defaults to Authorized). + * @param input.depositRailsSummary - Optional non-PII deposit readiness cache. + * @param input.updatedAt - Optional epoch ms timestamp (defaults to now). * @returns A new {@link AutorampAccount}. */ export function createAutorampAccount(input: { @@ -200,8 +207,8 @@ export function applyAutorampRemoteStatus( const account: AutorampAccount = { ...local, id: remote.id, - customerId: remote.customerId || local.customerId, - walletAddress: remote.walletAddress || local.walletAddress, + customerId: remote.customerId ?? local.customerId, + walletAddress: remote.walletAddress ?? local.walletAddress, status: remoteStatus, lastSeenStatus: previousStatus, updatedAt: Date.now(), @@ -223,7 +230,9 @@ export function applyAutorampRemoteStatus( * @param account - Account to update. * @returns Account with `notifiedForStatus` set to current status. */ -export function markAutorampNotified(account: AutorampAccount): AutorampAccount { +export function markAutorampNotified( + account: AutorampAccount, +): AutorampAccount { return { ...account, notifiedForStatus: account.status, diff --git a/packages/ramps-controller/src/index.ts b/packages/ramps-controller/src/index.ts index 7912e635237..9b4642c524d 100644 --- a/packages/ramps-controller/src/index.ts +++ b/packages/ramps-controller/src/index.ts @@ -233,7 +233,6 @@ export type { NeoBankServiceGetMoonpayCustomerIdAction, NeoBankServiceGetWalletRegistrationStatusAction, NeoBankServiceRegisterSelfHostedWalletAction, - NeoBankServiceMethodActions, } from './NeoBankService-method-action-types.js'; export { NeoBankService, diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ce39a90775f..d796a2b02fe 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -9,19 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) -- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Add `TransactionPayController:submitMoneyAccountVaultDeposit` action to vault a completed mUSD payout into the Money Account vault, resolving the deposit amount from the payout transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Add `TransactionPayController:submitMoneyAccountVaultWithdraw` action to redeem vmUSD and transfer the resulting mUSD to a given recipient in a single atomic, user-confirmed batch ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ### Changed -- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849)) -- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849)) -- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823)) +- Slim `SubmitMoneyAccountVaultWithdrawRequest` to on-chain fields only (`amountInRaw`, `moneyAccountAddress`, `recipient`, `requestId`); quote / chain / token validation stays outside Core ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Return `{ skipped: true }` from Money Account vault deposit helpers when vaulting is disabled instead of a fake `0x` transaction hash ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Bump `@metamask/transaction-controller` from `^69.5.1` to `^69.5.2` ([#9823](https://github.com/MetaMask/core/pull/9823), [#9853](https://github.com/MetaMask/core/pull/9853)) ### Fixed -- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849)) -- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849)) +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [26.3.0] diff --git a/packages/transaction-pay-controller/src/TransactionPayController.test.ts b/packages/transaction-pay-controller/src/TransactionPayController.test.ts index c708945641d..e173155c555 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController.test.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController.test.ts @@ -196,9 +196,9 @@ describe('TransactionPayController', () => { controller.submitMoneyAccountVaultDeposit(request), ).rejects.toThrow('vault failed'); - await expect( - controller.submitMoneyAccountVaultDeposit(request), - ).resolves.toStrictEqual({ transactionHash }); + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ transactionHash }); expect( submitMoneyAccountVaultDepositFromPayoutMock, ).toHaveBeenCalledTimes(2); @@ -211,13 +211,13 @@ describe('TransactionPayController', () => { const controller = createController(); const request = { moneyAccountAddress, transactionHash }; - await expect( - controller.submitMoneyAccountVaultDeposit(request), - ).resolves.toStrictEqual({ skipped: true }); + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ skipped: true }); - await expect( - controller.submitMoneyAccountVaultDeposit(request), - ).resolves.toStrictEqual({ transactionHash }); + expect( + await controller.submitMoneyAccountVaultDeposit(request), + ).toStrictEqual({ transactionHash }); expect( submitMoneyAccountVaultDepositFromPayoutMock, ).toHaveBeenCalledTimes(2); @@ -308,9 +308,9 @@ describe('TransactionPayController', () => { controller.submitMoneyAccountVaultWithdraw(request), ).rejects.toThrow('batch failed'); - await expect( - controller.submitMoneyAccountVaultWithdraw(request), - ).resolves.toStrictEqual({ batchId: '0x123' }); + expect( + await controller.submitMoneyAccountVaultWithdraw(request), + ).toStrictEqual({ batchId: '0x123' }); expect(submitMoneyAccountVaultWithdrawUtilMock).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/transaction-pay-controller/src/utils/chomp.test.ts b/packages/transaction-pay-controller/src/utils/chomp.test.ts index f940befa94e..af4639ce278 100644 --- a/packages/transaction-pay-controller/src/utils/chomp.test.ts +++ b/packages/transaction-pay-controller/src/utils/chomp.test.ts @@ -11,8 +11,7 @@ const MONEY_ACCOUNT_ADDRESS = '0x1111111111111111111111111111111111111111' as Hex; const BORING_VAULT_ADDRESS = '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; -const OTHER_RECIPIENT = - '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const OTHER_RECIPIENT = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; const CHOMP_TX_HASH = '0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef' as Hex; const FROM_BLOCK = '0x100' as Hex; @@ -54,11 +53,7 @@ function buildMusdTransferLog({ return { address: MUSD_MONAD_ADDRESS, data, - topics: [ - ERC20_TRANSFER_TOPIC, - MONEY_ACCOUNT_PADDED, - padAddress(to), - ], + topics: [ERC20_TRANSFER_TOPIC, MONEY_ACCOUNT_PADDED, padAddress(to)], transactionHash: txHash, }; } From c34a16a378a7522c4d41ea46488db57f510c40a8 Mon Sep 17 00:00:00 2001 From: Amitabh Aggarwal Date: Thu, 13 Aug 2026 03:48:08 -0600 Subject: [PATCH 66/67] fix: unblock neobank-demo CI and resolve Cursor findings for #9853 (#9861) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Fixes the three real CI failures on [#9853](https://github.com/MetaMask/core/pull/9853) and resolves the outstanding Cursor/Bugbot findings. ### CI failures fixed | Failing check | Root cause | Fix | | --- | --- | --- | | `Build (24.x)` | `TS2345`/`TS2739` in `RampsController.addAutoramp` — the inline union check did not narrow `AutorampAccount` | Extracted an `isFullAutorampAccount` type guard | | `Lint (messenger-action-types:check)` | `TransactionPayController-method-action-types.ts` was stale after a JSDoc update to `vaultIronPayout` | Regenerated via `messenger-action-types:generate` | | `Test (18/20/22.x) (@metamask/ramps-controller)` | Coverage below thresholds (statements 96.94%, branches 93.50%, functions 97.04%, lines 96.96%) | Added tests; now 100% statements/functions/lines and 98.41% branches | ### Cursor/Bugbot findings - **Unsafe timer unref call** (high) — `#ensureUserStatusPolling` called `.unref()` directly, which throws in React Native/browser where timers are numbers. Now `unref?.()`. - **Unguarded status write after reset** (medium) — the `session_not_in_valid_state` path called `#applyUserStatus` with no generation check, so a `reset()` mid-flight could force `userStatus: completed` and publish `statusChanged` on an idle controller. Added the generation guard. - **Wrong vendor on customer identity** (medium) — `moonpayCustomerId` survived a switch to another vendor, so `getCustomerIdentity` could report a MoonPay id under `iron`. It is now cleared in `initialize` (non-MoonPay vendor) and `createIronCustomer`. - **Empty remote fields wipe local** (medium) — the `??` merge introduced for lint let an empty-string remote `customerId`/`walletAddress` overwrite valid local values. Restored explicit non-empty checks in `applyAutorampRemoteStatus` and `mapNeoBankAutorampToRemoteSnapshot`. ## Notes - The three defensive guards marked `/* istanbul ignore next */` in `controller-integration.ts` are unreachable through the public surface (every caller filters on `isSyncableAutoramp` or a non-empty storage key). The unused default parameter on the private `saveAutorampsToUserStorage` was dropped instead. ## Test plan - [x] `yarn workspace @metamask/ramps-controller run test` — pass, thresholds met - [x] `yarn workspace @metamask/kyc-controller run test` — pass, 100% coverage - [x] `yarn workspace @metamask/transaction-pay-controller run test` — pass - [x] `yarn build` — pass - [x] `yarn lint:tsc` — pass - [x] `yarn messenger-action-types:check` — pass - [x] `eslint` + `oxfmt --check` on all changed files — pass - [x] `changelog:validate` for the three packages — pass Made with [Cursor](https://cursor.com) --- packages/kyc-controller/CHANGELOG.md | 6 + .../kyc-controller/src/KycController.test.ts | 74 ++ packages/kyc-controller/src/KycController.ts | 21 +- packages/ramps-controller/CHANGELOG.md | 4 + .../ramps-controller/src/NeoBankService.ts | 5 +- .../src/RampsController.test.ts | 188 +++++ .../ramps-controller/src/RampsController.ts | 24 +- .../controller-integration.test.ts | 690 ++++++++++++++++++ .../controller-integration.ts | 11 +- .../src/autoramp-syncing/format-utils.test.ts | 29 + .../ramps-controller/src/autorampAccount.ts | 11 +- .../src/wallet-registration-service.test.ts | 25 + .../transaction-pay-controller/CHANGELOG.md | 2 +- ...actionPayController-method-action-types.ts | 5 +- 14 files changed, 1081 insertions(+), 14 deletions(-) create mode 100644 packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 0c857a9d1a2..34e465f3d5a 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -25,4 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Move Money Account wallet registration to `@metamask/ramps-controller`: removes `KycController.registerMoneyAccountWallet`, the `KycService` wallet-registration methods (`getMoonpayCustomerId`, `getWalletRegistrationStatus`, `registerSelfHostedWallet`), the `neobankBaseUrl` service option, and the wallet registration exports (`WalletRegistrationError`, `SelfHostedRegistration`, `MoneyAccountWalletRegistrationResult`, and related types). Wallet ownership signing is a Money Movement (neobank-proxy) concern, so it now lives on `RampsController` / `NeoBankService`. ([#9853](https://github.com/MetaMask/core/pull/9853)) +### Fixed + +- Clear `moonpayCustomerId` when the active vendor changes, so `getCustomerIdentity()` can no longer report a MoonPay customer id under another vendor. The id is dropped when `initialize` starts a non-MoonPay flow and when `createIronCustomer` switches to Iron. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Call `unref()` on the user-status poll timer only when it exists. React Native and browser timers are numbers, so the unconditional call threw when Money status polling started outside Node. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Skip the `session_not_in_valid_state` completion write when a `reset()` superseded the SumSub flow, so a late vendor response can no longer force `userStatus` to `completed` (and publish `statusChanged`) on an idle controller. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index aef1d4f36e9..c34d6a88eed 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1251,6 +1251,53 @@ describe('KycController', () => { }, ); }); + + it('drops a MoonPay id when initialize switches to another vendor', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'iron' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); + + it('keeps a MoonPay id when initialize stays on MoonPay', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.initialize({ vendor: 'moonpay' }); + + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('drops a MoonPay id when an Iron customer is created', async () => { + await withController( + { + options: { + state: { moonpayCustomerId: 'cust-1', activeVendor: 'moonpay' }, + }, + }, + async ({ controller }) => { + await controller.createIronCustomer({ email: 'a@b.co' }); + + expect(controller.state.moonpayCustomerId).toBeNull(); + expect(controller.getCustomerIdentity()).toBeNull(); + }, + ); + }); }); describe('startSumSub', () => { @@ -2441,6 +2488,33 @@ describe('KycController', () => { ); }); + it('leaves an already-reset controller idle when SumSub reports a stale session', async () => { + await withController( + { + options: { + state: { activeVendor: 'iron', phase: 'submit' }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (error: Error) => void = () => undefined; + handlers.createUkycSession.mockReturnValue( + new Promise((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.startSumSub(); + controller.reset(); + rejectSession(new Error('session_not_in_valid_state')); + + expect(await pending).toStrictEqual({ alreadyCompleted: true }); + expect(controller.state.userStatus).toBeNull(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + it('keeps phase done when Iron SumSub reports already completed', async () => { await withController( { diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index 29e7f21e12e..6a4d98520c3 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -633,6 +633,12 @@ export class KycController extends BaseController< state.email = params.email; } state.activeVendor = vendor; + // `moonpayCustomerId` is only ever issued by the MoonPay Check / Auth + // frames. Leaving it set while the flow switches to another vendor would + // make `getCustomerIdentity` report a MoonPay id under the wrong vendor. + if (vendor !== 'moonpay') { + state.moonpayCustomerId = null; + } state.activeProduct = params?.product ?? null; }); @@ -703,6 +709,9 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.email = params.email; state.activeVendor = 'iron'; + // See `initialize`: a MoonPay-issued customer id must not survive a + // switch to Iron, or `getCustomerIdentity` reports the wrong vendor. + state.moonpayCustomerId = null; }); const generation = this.#generation; try { @@ -1537,6 +1546,12 @@ export class KycController extends BaseController< } catch (error) { // Applicant already finished KYC — treat as completed for Money toast. if (String(error).includes(SESSION_NOT_IN_VALID_STATE)) { + // A reset() may have landed while `launch` was in flight; forcing + // `completed` (and publishing `statusChanged`) on an idle controller + // would resurrect a flow the consumer already tore down. + if (this.#generation !== generation) { + return { alreadyCompleted: true }; + } this.#applyUserStatus({ status: 'completed', sumsubSessionId: null, @@ -1670,14 +1685,16 @@ export class KycController extends BaseController< tick(); }, this.#userStatusPollIntervalMs); // Allow the process to exit while a pending-status poll is scheduled. - this.#userStatusPollTimer.unref(); + // React Native / browser timers are numbers with no `unref`, hence the + // optional call. + this.#userStatusPollTimer.unref?.(); }; this.#userStatusPollTimer = setTimeout(() => { this.#userStatusPollTimer = null; // eslint-disable-next-line @typescript-eslint/no-floating-promises tick(); }, this.#userStatusPollIntervalMs); - this.#userStatusPollTimer.unref(); + this.#userStatusPollTimer.unref?.(); } /** diff --git a/packages/ramps-controller/CHANGELOG.md b/packages/ramps-controller/CHANGELOG.md index 34d66549c04..97b6eb6e827 100644 --- a/packages/ramps-controller/CHANGELOG.md +++ b/packages/ramps-controller/CHANGELOG.md @@ -22,6 +22,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Resolve autoramp / Money Account wallet-registration customer id only via Profile Sync + `NeoBankService:getCustomerByExternalId` (prefer `canonicalProfileId`, else `profileId`). Stop calling `KycController:getCustomerIdentity` from ramps; remove the local `KycControllerGetCustomerIdentityAction` type and drop that action from `RAMPS_CONTROLLER_REQUIRED_CONTROLLER_ACTIONS`. ([#9859](https://github.com/MetaMask/core/pull/9859), [#9853](https://github.com/MetaMask/core/pull/9853)) - Point `NeoBankService.getAutoramp` at `GET /neobank/autoramps/{id}` (neobank-proxy global `/neobank` prefix) instead of `/api/v2/autoramps/{id}`, so Core matches the proxy that ships. ([#9853](https://github.com/MetaMask/core/pull/9853)) +### Fixed + +- Keep the local `customerId` / `walletAddress` when a remote autoramp snapshot omits or blanks them. The proxy sends empty identity fields on partial status pushes, and `applyAutorampRemoteStatus` / `mapNeoBankAutorampToRemoteSnapshot` treated those as a clear, wiping valid local values during refresh-on-load and websocket pushes. ([#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) + ## [20.0.0] ### Changed diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index cbc358c03f2..76a9aecb4a9 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -179,7 +179,10 @@ export function mapNeoBankAutorampToRemoteSnapshot( id: response.id, customerId: response.customer_id, walletAddress: - response.wallet_address ?? response.recipient_account?.address, + response.wallet_address !== undefined && + response.wallet_address.length > 0 + ? response.wallet_address + : response.recipient_account?.address, status: response.status, depositRailsSummary, }; diff --git a/packages/ramps-controller/src/RampsController.test.ts b/packages/ramps-controller/src/RampsController.test.ts index e6f3b43aaa8..44a5388eb9c 100644 --- a/packages/ramps-controller/src/RampsController.test.ts +++ b/packages/ramps-controller/src/RampsController.test.ts @@ -9305,6 +9305,194 @@ describe('RampsController', () => { ); }); }); + + /** + * Registers the User Storage / auth handlers that let the incremental + * autoramp pushes run, so tests can drive the remote-write code paths. + * + * @param rootMessenger - Root messenger of the controller under test. + * @param batchSet - Handler for `performBatchSetStorage`. + */ + function registerAutorampSyncHandlers( + rootMessenger: RootMessenger, + batchSet: jest.Mock, + ): void { + rootMessenger.registerActionHandler( + 'UserStorageController:getState', + () => ({ isBackupAndSyncEnabled: true }) as never, + ); + rootMessenger.registerActionHandler( + 'AuthenticationController:isSignedIn', + () => true, + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performGetStorageAllFeatureEntries', + async () => [], + ); + rootMessenger.registerActionHandler( + 'UserStorageController:performBatchSetStorage', + batchSet, + ); + } + + /** + * Lets floating remote-push promises settle. + */ + async function flushPromises(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + it('updates an existing autoramp when the id is already known', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const updated = controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xdef', + status: AutorampStatus.Approved, + }); + + expect(controller.state.autoramps).toHaveLength(1); + expect(updated.walletAddress).toBe('0xdef'); + expect(updated.status).toBe(AutorampStatus.Approved); + }); + }); + + it('ignores removal and notification for unknown autoramp ids', async () => { + await withController(({ controller }) => { + controller.removeAutoramp('missing'); + controller.markAutorampAsNotified('missing'); + + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('queues a remote delete when a full sync holds the semaphore', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + controller.setIsAutorampSyncingInProgress(true); + controller.removeAutoramp('ar-1'); + + const pending = controller.getPendingRemoteAutorampDeletes(); + expect(pending.map((account) => account.id)).toStrictEqual(['ar-1']); + + controller.acknowledgePendingRemoteAutorampDeletes([]); + expect(controller.getPendingRemoteAutorampDeletes()).toHaveLength(1); + + controller.acknowledgePendingRemoteAutorampDeletes(pending); + expect(controller.getPendingRemoteAutorampDeletes()).toStrictEqual([]); + + controller.setIsAutorampSyncingInProgress(false); + }); + }); + + it('suppresses remote pushes while applying sync changes locally', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockResolvedValue(undefined); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.setIsApplyingAutorampSyncChanges(true); + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + }); + controller.markAutorampAsNotified('ar-1'); + controller.removeAutoramp('ar-1'); + controller.setIsApplyingAutorampSyncChanges(false); + + await flushPromises(); + + expect(batchSet).not.toHaveBeenCalled(); + }); + }); + + it('swallows remote storage failures raised by autoramp mutations', async () => { + await withController(async ({ controller, rootMessenger }) => { + const batchSet = jest.fn().mockRejectedValue(new Error('storage down')); + registerAutorampSyncHandlers(rootMessenger, batchSet); + + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + await flushPromises(); + + controller.markAutorampAsNotified('ar-1'); + await flushPromises(); + + controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + await flushPromises(); + + controller.removeAutoramp('ar-1'); + await flushPromises(); + + expect(batchSet).toHaveBeenCalled(); + expect(controller.state.autoramps).toStrictEqual([]); + }); + }); + + it('creates an autoramp from a push that carries no wallet address', async () => { + await withController(({ controller }) => { + const created = controller.applyAutorampStatusFromPush({ + id: 'ar-new', + customerId: 'cust-1', + status: AutorampStatus.Approved, + }); + + expect(created.walletAddress).toBe(''); + expect(controller.state.autoramps).toHaveLength(1); + }); + }); + + it('keeps local identity fields when a remote push omits or blanks them', async () => { + await withController(({ controller }) => { + controller.addAutoramp({ + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Authorized, + }); + + const afterOmitted = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + status: AutorampStatus.Approved, + }); + + expect(afterOmitted.customerId).toBe('cust-1'); + expect(afterOmitted.walletAddress).toBe('0xabc'); + + const afterBlank = controller.applyAutorampStatusFromPush({ + id: 'ar-1', + customerId: '', + walletAddress: '', + status: AutorampStatus.Approved, + }); + + expect(afterBlank.customerId).toBe('cust-1'); + expect(afterBlank.walletAddress).toBe('0xabc'); + }); + }); }); describe('registerMoneyAccountWallet', () => { diff --git a/packages/ramps-controller/src/RampsController.ts b/packages/ramps-controller/src/RampsController.ts index e691bbf6233..fe7e0fbc5e1 100644 --- a/packages/ramps-controller/src/RampsController.ts +++ b/packages/ramps-controller/src/RampsController.ts @@ -316,6 +316,22 @@ function hasHttpStatus(error: unknown): error is ErrorWithHttpStatus { ); } +/** + * Distinguishes an already-materialized {@link AutorampAccount} from the + * create-fields shape accepted by {@link RampsController.addAutoramp}. + * + * @param value - Full account or create fields. + * @returns Whether the value already carries the derived account fields. + */ +function isFullAutorampAccount( + value: AutorampAccount | { id: string; customerId: string }, +): value is AutorampAccount { + return ( + typeof (value as AutorampAccount).updatedAt === 'number' && + (value as AutorampAccount).lastSeenStatus !== undefined + ); +} + function getRampsErrorInfo(error: unknown): RampsErrorInfo { if (error instanceof BrokenCircuitError && hasStringMessage(error)) { return { @@ -2670,11 +2686,9 @@ export class RampsController extends BaseController< status?: AutorampAccount['status'] | string; }, ): AutorampAccount { - const account = - typeof (accountOrInput as AutorampAccount).updatedAt === 'number' && - (accountOrInput as AutorampAccount).lastSeenStatus !== undefined - ? accountOrInput - : createAutorampAccount(accountOrInput); + const account: AutorampAccount = isFullAutorampAccount(accountOrInput) + ? accountOrInput + : createAutorampAccount(accountOrInput); this.update((state) => { const idx = state.autoramps.findIndex( diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts new file mode 100644 index 00000000000..0d8195cf10c --- /dev/null +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.test.ts @@ -0,0 +1,690 @@ +import type { AutorampAccount } from '../autorampAccount.js'; +import { AutorampStatus, createAutorampAccount } from '../autorampAccount.js'; +import { + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + USER_STORAGE_VERSION, + USER_STORAGE_VERSION_KEY, +} from './constants.js'; +import { + computeAutorampMergePlan, + deleteAutorampInRemoteStorage, + syncAutorampsWithUserStorage, + updateAutorampInRemoteStorage, +} from './controller-integration.js'; +import { mapAutorampToUserStorageEntry } from './format-utils.js'; +import type { + AutorampSyncingController, + AutorampSyncingOptions, + SyncAutorampAccount, +} from './types.js'; + +/** + * Builds an autoramp account with sync-relevant defaults. + * + * @param overrides - Fields to override on the generated account. + * @returns A sync-aware autoramp account. + */ +function buildAccount( + overrides: Partial & { id: string }, +): SyncAutorampAccount { + return { + ...createAutorampAccount({ + customerId: 'customer-1', + walletAddress: '0xwallet', + status: AutorampStatus.Authorized, + updatedAt: 1_000, + ...overrides, + }), + ...(overrides.deletedAt === undefined + ? {} + : { deletedAt: overrides.deletedAt }), + }; +} + +/** + * Serializes an account the way User Storage would return it. + * + * @param account - Account to serialize. + * @returns JSON string of the remote entry. + */ +function toRemoteEntryJson(account: SyncAutorampAccount): string { + return JSON.stringify(mapAutorampToUserStorageEntry(account)); +} + +type Harness = { + options: AutorampSyncingOptions; + controller: jest.Mocked & { + state: { autoramps: AutorampAccount[] }; + }; + call: jest.Mock; + onAutorampSyncErroneousSituation: jest.Mock; + batchSetCalls: () => [string, string][][]; +}; + +/** + * Builds a sync test harness with a stubbed controller and messenger. + * + * @param args - Harness configuration. + * @param args.localAccounts - Accounts present in controller state. + * @param args.remoteEntries - Raw JSON entries returned by User Storage. + * @param args.pendingDeletes - Accounts queued for remote soft-delete. + * @param args.canSync - Whether the Backup & Sync gates should pass. + * @param args.trace - Optional trace callback. + * @returns The harness. + */ +function buildHarness({ + localAccounts = [], + remoteEntries = [], + pendingDeletes = [], + canSync = true, + trace, +}: { + localAccounts?: AutorampAccount[]; + remoteEntries?: (string | null)[]; + pendingDeletes?: AutorampAccount[]; + canSync?: boolean; + trace?: AutorampSyncingOptions['trace']; +} = {}): Harness { + const batchSetCalls: [string, string][][] = []; + + const call = jest.fn((action: string, ...args: unknown[]) => { + switch (action) { + case 'UserStorageController:getState': + return { isBackupAndSyncEnabled: canSync }; + case 'AuthenticationController:isSignedIn': + return canSync; + case 'UserStorageController:performGetStorageAllFeatureEntries': + return remoteEntries; + case 'UserStorageController:performBatchSetStorage': + batchSetCalls.push(args[1] as [string, string][]); + return undefined; + default: + throw new Error(`unexpected action ${action}`); + } + }); + + const state = { autoramps: [...localAccounts] }; + + const controller = { + state, + isAutorampSyncingInProgress: false, + setIsAutorampSyncingInProgress: jest.fn(), + setIsApplyingAutorampSyncChanges: jest.fn(), + addAutoramp: jest.fn((account: AutorampAccount) => { + const index = state.autoramps.findIndex( + (entry) => entry.id === account.id, + ); + if (index === -1) { + state.autoramps.push(account); + } else { + state.autoramps[index] = account; + } + return account; + }), + removeAutoramp: jest.fn((autorampId: string) => { + state.autoramps = state.autoramps.filter( + (entry) => entry.id !== autorampId, + ); + controller.state.autoramps = state.autoramps; + }), + getPendingRemoteAutorampDeletes: jest.fn(() => pendingDeletes), + acknowledgePendingRemoteAutorampDeletes: jest.fn(), + } as unknown as Harness['controller']; + + const onAutorampSyncErroneousSituation = jest.fn(); + + return { + options: { + getRampsControllerInstance: () => controller, + getMessenger: () => ({ call }) as never, + ...(trace ? { trace } : {}), + }, + controller, + call, + onAutorampSyncErroneousSituation, + batchSetCalls: () => batchSetCalls, + }; +} + +describe('computeAutorampMergePlan', () => { + it('ignores remote tombstones for accounts that are absent locally', () => { + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([], [remote]); + + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('re-uploads a local account that is newer than a remote tombstone', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 9_000 }); + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToUpdateRemotely.map((account) => account.id), + ).toStrictEqual(['ar-1']); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + }); + + it('treats a local account with no timestamp as older than a tombstone', () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: undefined, + } as unknown as SyncAutorampAccount; + const remote = buildAccount({ id: 'ar-1', deletedAt: 5_000 }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToDeleteLocally.map((account) => account.id), + ).toStrictEqual(['ar-1']); + }); + + it('imports the remote account when it is newer than the local copy', () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 2_000, + }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect( + plan.accountsToAddOrUpdateLocally.map((a) => a.status), + ).toStrictEqual([AutorampStatus.Approved]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + }); + + it('plans no work when local and remote accounts match', () => { + const local = buildAccount({ id: 'ar-1' }); + const remote = buildAccount({ id: 'ar-1' }); + + const plan = computeAutorampMergePlan([local], [remote]); + + expect(plan.accountsToAddOrUpdateLocally).toStrictEqual([]); + expect(plan.accountsToDeleteLocally).toStrictEqual([]); + expect(plan.accountsToUpdateRemotely).toStrictEqual([]); + expect([...plan.remoteAccountsMap.keys()]).toStrictEqual(['ar-1']); + }); +}); + +describe('syncAutorampsWithUserStorage', () => { + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).not.toHaveBeenCalled(); + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('returns early when User Storage holds no entries', async () => { + const harness = buildHarness({ remoteEntries: [] }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.batchSetCalls()).toStrictEqual([]); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenCalledWith(false); + }); + + it('treats a null feature-entries response as empty', async () => { + const harness = buildHarness(); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + if ( + action === 'UserStorageController:performGetStorageAllFeatureEntries' + ) { + return null; + } + throw new Error(`unexpected action ${action}`); + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('imports remote-only accounts into controller state', async () => { + const remote = buildAccount({ id: 'ar-remote', updatedAt: 2_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(remote)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).toHaveBeenCalledWith( + expect.objectContaining({ id: 'ar-remote' }), + ); + expect( + harness.controller.setIsApplyingAutorampSyncChanges.mock.calls, + ).toStrictEqual([[true], [false]]); + }); + + it('uploads local-only accounts to User Storage', async () => { + const local = buildAccount({ id: 'ar-local', updatedAt: 3_000 }); + const other = buildAccount({ id: 'ar-other', updatedAt: 4_000 }); + const harness = buildHarness({ + localAccounts: [local, other], + remoteEntries: [toRemoteEntryJson(other)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-local']); + }); + + it('stamps an upload that has no local timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-local' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-untouched' }))], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.call).toHaveBeenCalledWith( + 'UserStorageController:performBatchSetStorage', + USER_STORAGE_RAMPS_AUTORAMPS_FEATURE, + expect.any(Array), + ); + const [entries] = harness.batchSetCalls(); + const uploaded = entries.find(([key]) => key === 'ar-local'); + expect(uploaded).toBeDefined(); + expect(JSON.parse((uploaded as [string, string])[1]).lu).toBeGreaterThan(0); + }); + + it('deletes local accounts that were tombstoned remotely', async () => { + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const tombstone = buildAccount({ + id: 'ar-1', + updatedAt: 5_000, + deletedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(tombstone)], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.removeAutoramp).toHaveBeenCalledWith('ar-1'); + }); + + it('does not re-import a remote account that is queued for local deletion', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(pending)], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('uploads tombstones for pending remote deletes and acknowledges them', async () => { + const pending = buildAccount({ id: 'ar-pending', updatedAt: 1_000 }); + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [pending], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + const tombstone = entries.find(([key]) => key === 'ar-pending'); + expect(tombstone).toBeDefined(); + expect(JSON.parse((tombstone as [string, string])[1]).dt).toBeGreaterThan( + 0, + ); + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).toHaveBeenCalledWith([pending]); + }); + + it('ignores pending deletes that have no storage key', async () => { + const harness = buildHarness({ + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + pendingDeletes: [ + { ...buildAccount({ id: 'ar-pending' }), id: '' } as AutorampAccount, + ], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect( + harness.controller.acknowledgePendingRemoteAutorampDeletes, + ).not.toHaveBeenCalled(); + }); + + it('re-uploads a local account whose newer remote copy was not imported', async () => { + // The account is queued for deletion, so the newer remote copy is not + // applied locally; the surviving local copy still has to reach the remote. + const local = buildAccount({ id: 'ar-1', updatedAt: 1_000 }); + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + expect(JSON.parse(entries[0][1]).o.status).toBe(AutorampStatus.Authorized); + }); + + it('stamps a re-uploaded local account that has no timestamp', async () => { + const local = { + ...buildAccount({ id: 'ar-1' }), + updatedAt: 0, + } as unknown as AutorampAccount; + const remote = buildAccount({ + id: 'ar-1', + status: AutorampStatus.Approved, + updatedAt: 5_000, + }); + const harness = buildHarness({ + localAccounts: [local], + remoteEntries: [toRemoteEntryJson(remote)], + pendingDeletes: [local], + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).lu).toBeGreaterThan(0); + }); + + it('reports an unsupported storage version and skips the entry', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: '999', + o: { id: 'ar-1' }, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Unsupported autoramp storage version', + { version: '999', expectedVersion: USER_STORAGE_VERSION }, + ); + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + }); + + it('reports a remote entry that is missing its payload', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Remote autoramp entry missing payload', + {}, + ); + }); + + it('reports a remote entry that cannot be parsed', async () => { + const harness = buildHarness({ remoteEntries: ['not json'] }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Failed to parse remote autoramp entry', + expect.objectContaining({ entryLength: 'not json'.length }), + ); + }); + + it('skips a remote entry whose payload has no id', async () => { + const harness = buildHarness({ + remoteEntries: [ + JSON.stringify({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: '', + customerId: 'c', + walletAddress: '0x1', + status: AutorampStatus.Authorized, + lastSeenStatus: AutorampStatus.Authorized, + }, + lu: 1_000, + }), + ], + }); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.controller.addAutoramp).not.toHaveBeenCalled(); + expect(harness.onAutorampSyncErroneousSituation).not.toHaveBeenCalled(); + }); + + it('skips a remote write whose account has an empty storage key', async () => { + const harness = buildHarness({ + localAccounts: [ + { ...buildAccount({ id: 'ar-local' }), id: '' } as AutorampAccount, + ], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + }); + harness.controller.getPendingRemoteAutorampDeletes.mockReturnValue([]); + + await syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('reports and rethrows when the sync fails', async () => { + const harness = buildHarness(); + const failure = new Error('storage down'); + harness.call.mockImplementation((action: string) => { + if (action === 'UserStorageController:getState') { + return { isBackupAndSyncEnabled: true }; + } + if (action === 'AuthenticationController:isSignedIn') { + return true; + } + throw failure; + }); + + await expect( + syncAutorampsWithUserStorage( + { + onAutorampSyncErroneousSituation: + harness.onAutorampSyncErroneousSituation, + }, + harness.options, + ), + ).rejects.toThrow('storage down'); + + expect(harness.onAutorampSyncErroneousSituation).toHaveBeenCalledWith( + 'Error synchronizing autoramps', + { error: failure }, + ); + expect( + harness.controller.setIsAutorampSyncingInProgress, + ).toHaveBeenLastCalledWith(false); + }); + + it('wraps the sync and the batch save in traces when a callback is given', async () => { + const traceNames: string[] = []; + const trace = jest.fn( + async (request: { name: string }, fn?: () => unknown) => { + traceNames.push(request.name); + return await (fn as () => Promise)(); + }, + ) as unknown as AutorampSyncingOptions['trace']; + + const harness = buildHarness({ + localAccounts: [buildAccount({ id: 'ar-local' })], + remoteEntries: [toRemoteEntryJson(buildAccount({ id: 'ar-other' }))], + trace, + }); + + await syncAutorampsWithUserStorage({}, harness.options); + + expect(traceNames).toStrictEqual([ + 'Ramps Autoramp Sync Full', + 'Ramps Autoramp Sync Save Batch', + ]); + }); +}); + +describe('updateAutorampInRemoteStorage', () => { + it('writes the account with a refreshed timestamp', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(entries.map(([key]) => key)).toStrictEqual(['ar-1']); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account that is not syncable', async () => { + const harness = buildHarness(); + + await updateAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await updateAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); + +describe('deleteAutorampInRemoteStorage', () => { + it('writes a tombstone for the account', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + const [entries] = harness.batchSetCalls(); + expect(JSON.parse(entries[0][1]).dt).toBeGreaterThan(0); + }); + + it('does nothing when syncing is not permitted', async () => { + const harness = buildHarness({ canSync: false }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('does nothing for an account with no id', async () => { + const harness = buildHarness(); + + await deleteAutorampInRemoteStorage( + { ...buildAccount({ id: 'ar-1' }), id: '' }, + harness.options, + ); + + expect(harness.batchSetCalls()).toStrictEqual([]); + }); + + it('wraps the tombstone write in a trace when a callback is given', async () => { + const trace = jest.fn(async (_request: unknown, fn?: () => unknown) => + (fn as () => Promise)(), + ) as unknown as AutorampSyncingOptions['trace']; + const harness = buildHarness({ trace }); + + await deleteAutorampInRemoteStorage( + buildAccount({ id: 'ar-1' }), + harness.options, + ); + + expect(trace).toHaveBeenCalled(); + expect(harness.batchSetCalls()).toHaveLength(1); + }); +}); diff --git a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts index 28e99d31a82..e6c03c52be4 100644 --- a/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts +++ b/packages/ramps-controller/src/autoramp-syncing/controller-integration.ts @@ -153,7 +153,7 @@ async function getRemoteAutoramps( async function saveAutorampsToUserStorage( accounts: SyncAutorampAccount[], options: AutorampSyncingOptions, - config: SyncAutorampsWithUserStorageConfig = {}, + config: SyncAutorampsWithUserStorageConfig, ): Promise { const { getMessenger, trace } = options; const { onAutorampSyncErroneousSituation } = config; @@ -162,6 +162,9 @@ async function saveAutorampsToUserStorage( const storageEntries: [string, string][] = []; for (const account of accounts) { const key = createAutorampStorageKey(account); + // Defensive: every caller filters on `isSyncableAutoramp` or a non-empty + // key before reaching here, so an id-less account is unreachable today. + /* istanbul ignore next */ if (!key) { onAutorampSyncErroneousSituation?.( 'Skipping autoramp remote write with empty storage key', @@ -174,6 +177,9 @@ async function saveAutorampsToUserStorage( JSON.stringify(mapAutorampToUserStorageEntry(account)), ]); } + // Defensive: only reachable if every account was skipped above, which the + // callers' filtering already rules out. + /* istanbul ignore next */ if (storageEntries.length === 0) { return; } @@ -289,6 +295,9 @@ export async function syncAutorampsWithUserStorage( ...getLocalAccounts() .filter((account) => { const key = createAutorampStorageKey(account); + // Defensive: `pendingDeletes` already excludes anything still + // present locally, so this cannot match a local account. + /* istanbul ignore next */ if (pendingDeleteKeys.has(key)) { return false; } diff --git a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts index 540cf7e88cd..a70dfa16a17 100644 --- a/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts +++ b/packages/ramps-controller/src/autoramp-syncing/format-utils.test.ts @@ -61,6 +61,35 @@ describe('autoramp-syncing/format-utils', () => { expect(stripAutorampSyncMetadata(mapped)).not.toHaveProperty('deletedAt'); }); + it('stamps the current time when the account has no update timestamp', () => { + const entry = mapAutorampToUserStorageEntry({ + ...account, + updatedAt: 0, + }); + + expect(entry.lu).toBeGreaterThan(0); + expect(entry.o).not.toHaveProperty('notifiedForStatus'); + expect(entry).not.toHaveProperty('dt'); + }); + + it('normalizes a notified status and defaults a missing timestamp', () => { + const mapped = mapUserStorageEntryToAutoramp({ + [USER_STORAGE_VERSION_KEY]: USER_STORAGE_VERSION, + o: { + id: 'ar-1', + customerId: 'cust-1', + walletAddress: '0xabc', + status: AutorampStatus.Approved, + lastSeenStatus: AutorampStatus.Approved, + notifiedForStatus: AutorampStatus.Approved, + }, + }); + + expect(mapped.notifiedForStatus).toBe(AutorampStatus.Approved); + expect(mapped.updatedAt).toBeGreaterThan(0); + expect(mapped).not.toHaveProperty('deletedAt'); + }); + it('compares sync-relevant fields', () => { expect(areAutorampsEqual(account, { ...account })).toBe(true); expect( diff --git a/packages/ramps-controller/src/autorampAccount.ts b/packages/ramps-controller/src/autorampAccount.ts index 03fe6e939a7..eca7f7fa382 100644 --- a/packages/ramps-controller/src/autorampAccount.ts +++ b/packages/ramps-controller/src/autorampAccount.ts @@ -207,8 +207,15 @@ export function applyAutorampRemoteStatus( const account: AutorampAccount = { ...local, id: remote.id, - customerId: remote.customerId ?? local.customerId, - walletAddress: remote.walletAddress ?? local.walletAddress, + // A blank remote identity field means "not supplied", not "cleared": the + // proxy omits or empties these on partial status pushes, so keep the local + // value rather than wiping it. + customerId: + remote.customerId.length > 0 ? remote.customerId : local.customerId, + walletAddress: + remote.walletAddress !== undefined && remote.walletAddress.length > 0 + ? remote.walletAddress + : local.walletAddress, status: remoteStatus, lastSeenStatus: previousStatus, updatedAt: Date.now(), diff --git a/packages/ramps-controller/src/wallet-registration-service.test.ts b/packages/ramps-controller/src/wallet-registration-service.test.ts index c3be35aedeb..d76327eeba0 100644 --- a/packages/ramps-controller/src/wallet-registration-service.test.ts +++ b/packages/ramps-controller/src/wallet-registration-service.test.ts @@ -87,6 +87,31 @@ describe('createIdempotencyKey', () => { expect(createIdempotencyKey().length).toBeGreaterThan(0); }); + // `globalThis.crypto.randomUUID` is absent under Node 18, so the preferred + // path has to be exercised against an installed stub rather than the ambient + // runtime. + it('prefers randomUUID when the runtime provides it', () => { + const originalDescriptor = Object.getOwnPropertyDescriptor( + globalThis, + 'crypto', + ); + Object.defineProperty(globalThis, 'crypto', { + configurable: true, + value: { randomUUID: () => 'uuid-1' }, + }); + try { + expect(createIdempotencyKey()).toBe('uuid-1'); + } finally { + if (originalDescriptor) { + Object.defineProperty(globalThis, 'crypto', originalDescriptor); + } else { + // Node 18 exposes no own `crypto` descriptor, so the stub has to be + // removed rather than restored, or it leaks into later tests. + Reflect.deleteProperty(globalThis, 'crypto'); + } + } + }); + it('falls back when randomUUID is unavailable', () => { const originalDescriptor = Object.getOwnPropertyDescriptor( globalThis, diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index d796a2b02fe..1841e2ff895 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -20,7 +20,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed -- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) +- Persist successful Money Account vault deposit and withdraw results for the controller lifetime so retries / webhook replays do not re-submit or open a second approval. Skipped results (vaulting disabled) are not retained, so a later enablement can retry the same payout hash. ([#9849](https://github.com/MetaMask/core/pull/9849), [#9861](https://github.com/MetaMask/core/pull/9861), [#9853](https://github.com/MetaMask/core/pull/9853)) - Match CHOMP vault deposits only when mUSD is transferred to the boring vault with an exact source amount ([#9849](https://github.com/MetaMask/core/pull/9849), [#9853](https://github.com/MetaMask/core/pull/9853)) ## [26.3.0] diff --git a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts index 0301e9ba2fe..09e0ae9eb78 100644 --- a/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts +++ b/packages/transaction-pay-controller/src/TransactionPayController-method-action-types.ts @@ -53,8 +53,9 @@ export type TransactionPayControllerUpdateFiatPaymentAction = { * Vaults mUSD received in a completed Iron payout transaction. * * Concurrent calls for the same payout hash share one in-flight submission. - * Successful results are retained so retries return the prior hash without - * submitting again. + * Successful results are retained for the controller lifetime so retries + * return the prior hash without submitting again. Skipped results (vaulting + * disabled) are not retained, so a later enablement can retry the same hash. * * @param request - Completed Iron payout details. * @returns Hash of the confirmed vault transaction, or `{ skipped: true }` From 8bc742f90099008171b92899b723ba12516b216c Mon Sep 17 00:00:00 2001 From: George Weiler Date: Thu, 13 Aug 2026 08:10:01 -0600 Subject: [PATCH 67/67] fix(ramps): stop retrying permanent neobank 4xx failures A single autoramp creation attempt fired the same request four times because the service policy retried MoonPay's 403 "Customer is not active", which is a statement about account state and never resolves by re-issuing the request. Filter retries to 429, 5xx, and non-HTTP errors, matching the existing chomp-api-service and subscription-controller policies. Also surface the upstream response body in HttpError. The neobank proxy mirrors MoonPay's body verbatim and it is usually the only explanation of a 4xx, so dropping it left failures undiagnosable. Co-authored-by: Cursor --- .../ramps-controller/src/NeoBankService.ts | 68 ++++++++++++++++--- 1 file changed, 58 insertions(+), 10 deletions(-) diff --git a/packages/ramps-controller/src/NeoBankService.ts b/packages/ramps-controller/src/NeoBankService.ts index 76a9aecb4a9..bfefa07ac36 100644 --- a/packages/ramps-controller/src/NeoBankService.ts +++ b/packages/ramps-controller/src/NeoBankService.ts @@ -2,7 +2,11 @@ import type { CreateServicePolicyOptions, ServicePolicy, } from '@metamask/controller-utils'; -import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import { + createServicePolicy, + handleWhen, + HttpError, +} from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; @@ -24,6 +28,27 @@ import type { */ export const serviceName = 'NeoBankService'; +/** + * Determines whether a failed neo-bank request is worth re-issuing. + * + * 4xx responses describe the request or the account's state (e.g. 403 + * "Customer is not active", 422 validation), so repeating them only multiplies + * the same rejection. 429 stays retryable alongside 5xx and non-HTTP + * network/timeout errors. + * + * @param error - Error thrown while performing the request. + * @returns `true` when the error is worth retrying. + */ +function isRetryableError(error: unknown): boolean { + if (error instanceof HttpError) { + if (error.httpStatus === 429) { + return true; + } + return error.httpStatus < 400 || error.httpStatus >= 500; + } + return true; +} + /** * Raw autoramp payload from the MetaMask Ramp API neo-bank proxy. * Shape mirrors MoonPay Enterprise `GET /api/autoramps/{autoramp_id}`. @@ -232,7 +257,10 @@ export class NeoBankService { this.name = serviceName; this.#messenger = messenger; this.#fetch = fetchFunction; - this.#policy = createServicePolicy(policyOptions); + this.#policy = createServicePolicy({ + retryFilterPolicy: handleWhen(isRetryableError), + ...policyOptions, + }); this.#environment = environment; this.#context = context; this.#baseUrlOverride = baseUrlOverride; @@ -299,6 +327,32 @@ export class NeoBankService { return url; } + /** + * Throws an {@link HttpError} that carries the upstream response body. + * + * The neobank-proxy mirrors MoonPay's status *and* body verbatim, so the + * body is usually the only place that explains a 4xx (e.g. which field or + * permission was rejected). Dropping it makes failures undiagnosable. + * + * @param url - Request URL, for context in the message. + * @param response - Non-OK fetch response. + */ + async #throwHttpError(url: URL, response: Response): Promise { + let detail = ''; + try { + const body = (await response.text()).trim(); + if (body) { + detail = ` - ${body.slice(0, 500)}`; + } + } catch { + // Body already consumed or unreadable; the status alone still helps. + } + throw new HttpError( + response.status, + `Fetching '${url.toString()}' failed with status '${response.status}'${detail}`, + ); + } + async #getJson( path: string, query?: NeoBankQueryParams, @@ -308,10 +362,7 @@ export class NeoBankService { const headers = await this.#getRequestHeaders(); const fetchResponse = await this.#fetch(url, { headers }); if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); + await this.#throwHttpError(url, fetchResponse); } return fetchResponse.json() as Promise; }); @@ -332,10 +383,7 @@ export class NeoBankService { body: JSON.stringify(body), }); if (!fetchResponse.ok) { - throw new HttpError( - fetchResponse.status, - `Fetching '${url.toString()}' failed with status '${fetchResponse.status}'`, - ); + await this.#throwHttpError(url, fetchResponse); } return fetchResponse.json() as Promise; });