From 639fc11633ecb520fe2264ea028e130ecb10bdf2 Mon Sep 17 00:00:00 2001 From: maxime-oe Date: Tue, 12 May 2026 10:14:14 +0200 Subject: [PATCH 01/12] Release/976.0.0 (#8768) ## Explanation ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Low Risk** > Low risk release bookkeeping: only version bumps, dependency range updates, changelog entries, and lockfile updates with no runtime code changes in this PR. > > **Overview** > Bumps the monorepo version to `976.0.0` and releases new patch/minor versions of `@metamask/assets-controller` (`7.1.0`), `@metamask/assets-controllers` (`106.1.0`), `@metamask/bridge-controller` (`72.1.0`), `@metamask/bridge-status-controller` (`71.2.0`), and `@metamask/transaction-pay-controller` (`22.3.0`). > > Updates inter-package dependency ranges to reference the new workspace versions, refreshes corresponding changelog sections/compare links, and updates `yarn.lock` to reflect the new resolved workspace specs. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit e595f6e26b84794a1708e31a6085ff52c865f4f1. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- packages/assets-controller/CHANGELOG.md | 8 +++++-- packages/assets-controller/package.json | 4 ++-- packages/assets-controllers/CHANGELOG.md | 5 +++- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 7 +++++- packages/bridge-controller/package.json | 6 ++--- .../bridge-status-controller/CHANGELOG.md | 6 ++++- .../bridge-status-controller/package.json | 4 ++-- .../transaction-pay-controller/CHANGELOG.md | 9 ++++++- .../transaction-pay-controller/package.json | 10 ++++---- yarn.lock | 24 +++++++++---------- 12 files changed, 55 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index d82c93d5f4..ab37266a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "975.0.0", + "version": "976.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a77b975415..8bf3c45009 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.0] + ### Changed -- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) - Update `RpcDataSource` to prevent native `getEthBalance` fetching for Tempo chains ([#8638](https://github.com/MetaMask/core/pull/8638)) +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [7.0.1] @@ -468,7 +471,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.0...HEAD +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...@metamask/assets-controller@7.1.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.0...@metamask/assets-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...@metamask/assets-controller@7.0.0 [6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 6e5975d847..6e2d6bf18b 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "7.0.1", + "version": "7.1.0", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,7 +58,7 @@ "@ethersproject/providers": "^5.7.0", "@metamask/account-tree-controller": "^7.3.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controllers": "^106.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^12.0.0", diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 1505772e1a..66c0720033 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [106.1.0] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) @@ -3065,7 +3067,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.1.0...HEAD +[106.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...@metamask/assets-controllers@106.1.0 [106.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...@metamask/assets-controllers@106.0.1 [106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0 [105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 3030339eca..cd564ec5a6 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "106.0.1", + "version": "106.1.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index ec8f455eaf..8562d62508 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [72.1.0] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [72.0.2] @@ -1458,7 +1462,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.1.0...HEAD +[72.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...@metamask/bridge-controller@72.1.0 [72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.1...@metamask/bridge-controller@72.0.2 [72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.0...@metamask/bridge-controller@72.0.1 [72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...@metamask/bridge-controller@72.0.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 10b6b22511..168d488cf7 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "72.0.2", + "version": "72.1.0", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^106.1.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a4a502ba71..0152b893d5 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [71.2.0] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [71.1.2] @@ -1178,7 +1181,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.2.0...HEAD +[71.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...@metamask/bridge-status-controller@71.2.0 [71.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.1...@metamask/bridge-status-controller@71.1.2 [71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.0...@metamask/bridge-status-controller@71.1.1 [71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.0.0...@metamask/bridge-status-controller@71.1.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index aedf53d5dc..c55ccc9997 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "71.1.2", + "version": "71.2.0", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -54,7 +54,7 @@ "dependencies": { "@metamask/accounts-controller": "^38.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", + "@metamask/bridge-controller": "^72.1.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 78213bb5a2..8be1d83951 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,9 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.3.0] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) +- Bump `@metamask/bridge-status-controller` from `^71.1.2` to `^71.2.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [22.2.0] @@ -838,7 +844,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.0...HEAD +[22.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...@metamask/transaction-pay-controller@22.3.0 [22.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.1.0...@metamask/transaction-pay-controller@22.2.0 [22.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.2...@metamask/transaction-pay-controller@22.1.0 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.1...@metamask/transaction-pay-controller@22.0.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index c69f20d773..817833f55c 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "22.2.0", + "version": "22.3.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,11 +57,11 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^106.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", - "@metamask/bridge-status-controller": "^71.1.2", + "@metamask/bridge-controller": "^72.1.0", + "@metamask/bridge-status-controller": "^71.2.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/messenger": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index 584a18ddba..4c2c3023aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^7.0.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^7.1.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2777,7 +2777,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.3.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controllers": "npm:^106.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^106.0.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^106.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -3015,7 +3015,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^72.0.2, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^72.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -3025,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^106.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.0.0" @@ -3062,14 +3062,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^71.1.2, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^71.2.0, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: "@metamask/accounts-controller": "npm:^38.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" + "@metamask/bridge-controller": "npm:^72.1.0" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/keyring-controller": "npm:^25.5.0" @@ -5763,12 +5763,12 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^106.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" - "@metamask/bridge-status-controller": "npm:^71.1.2" + "@metamask/bridge-controller": "npm:^72.1.0" + "@metamask/bridge-status-controller": "npm:^71.2.0" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/messenger": "npm:^1.2.0" From b72359bfaefa62f824a3f5f082c5343449e78418 Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Tue, 12 May 2026 10:46:31 +0200 Subject: [PATCH 02/12] refactor: make trending token query params extensible (#8729) ## Explanation Makes trending token query params extensible so new API parameters can be added without requiring a core release. ### Changes: Extract TrendingTokensQueryParams type with an index signature to allow arbitrary params to pass through **BREAKING**: Rename sortBy => sort parameter in getTrendingTokens to match the actual API parameter name Update getTrendingTokens and getTrendingTokensURL to use spread operator instead of explicitly destructuring every param Impact: Mobile can now add new params directly in the getTrendingTokens call without waiting for a core update. ## References * Fixes [#12345](https://consensyssoftware.atlassian.net/browse/ASSETS-3146) ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Medium risk because it introduces a **breaking** rename from `sortBy` to `sort` in `getTrendingTokens`, which can silently affect downstream callers at compile time/runtime if not updated. URL/query construction logic is also generalized to pass through arbitrary params, which could change request shapes if misused. > > **Overview** > Refactors the v3 trending tokens API surface to be *extensible*: introduces and exports `TrendingTokensQueryParams` (with an index signature) and updates `getTrendingTokens`/`getTrendingTokensURL` to forward any additional query parameters to the endpoint. > > Includes a **breaking** parameter rename from `sortBy` to `sort` to match the backend API, updates default handling for `includeRwaData`/`usePriceApiData`, and adjusts tests plus the package changelog to reflect the new behavior (including a new pass-through query param test). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit b4e888b762c98dcdc984d662d8ca2e8c098c9c54. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- packages/assets-controllers/CHANGELOG.md | 9 ++ packages/assets-controllers/src/index.ts | 1 + .../src/token-service.test.ts | 29 ++++-- .../assets-controllers/src/token-service.ts | 97 ++++++------------- 4 files changed, 65 insertions(+), 71 deletions(-) diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 66c0720033..a990368966 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Export new type `TrendingTokensQueryParams` for extensible trending token query parameters ([#8729](https://github.com/MetaMask/core/pull/8729)) + +### Changed + +- **BREAKING:** `getTrendingTokens` now accepts `sort` instead of `sortBy` to match the API parameter name ([#8729](https://github.com/MetaMask/core/pull/8729)) +- `getTrendingTokens` and `getTrendingTokensURL` now accept arbitrary query parameters via an index signature on `TrendingTokensQueryParams`, allowing new API parameters to pass through without a core release ([#8729](https://github.com/MetaMask/core/pull/8729)) + ## [106.1.0] ### Changed diff --git a/packages/assets-controllers/src/index.ts b/packages/assets-controllers/src/index.ts index 8bc2c17247..dda1702b93 100644 --- a/packages/assets-controllers/src/index.ts +++ b/packages/assets-controllers/src/index.ts @@ -292,6 +292,7 @@ export { createFormatters } from './utils/formatters'; export type { SortTrendingBy, TrendingAsset, + TrendingTokensQueryParams, TokenSearchItem, TokenAsset, TokenRwaData, diff --git a/packages/assets-controllers/src/token-service.test.ts b/packages/assets-controllers/src/token-service.test.ts index 7ae819b9a8..e6362f154a 100644 --- a/packages/assets-controllers/src/token-service.test.ts +++ b/packages/assets-controllers/src/token-service.test.ts @@ -1139,7 +1139,7 @@ describe('Token service', () => { it('returns empty array if api returns non-array response', async () => { nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, ) .reply(200, { error: 'Invalid response' }) .persist(); @@ -1151,7 +1151,7 @@ describe('Token service', () => { it('returns empty array if the fetch fails', async () => { nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(sampleCaipChainId)}&includeRwaData=true&usePriceApiData=true`, ) .reply(500) .persist(); @@ -1162,7 +1162,7 @@ describe('Token service', () => { it('returns the list of trending tokens if the fetch succeeds', async () => { const testChainId = 'eip155:1'; - const sortBy: SortTrendingBy = 'm5_trending'; + const sort: SortTrendingBy = 'm5_trending'; const testMinLiquidity = 1000000; const testMinVolume24hUsd = 1000000; const testMaxVolume24hUsd = 1000000; @@ -1170,14 +1170,14 @@ describe('Token service', () => { const testMaxMarketCap = 1000000; nock(TOKEN_END_POINT_API) .get( - `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sortBy}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}&includeRwaData=true&usePriceApiData=true`, + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&sort=${sort}&minLiquidity=${testMinLiquidity}&minVolume24hUsd=${testMinVolume24hUsd}&maxVolume24hUsd=${testMaxVolume24hUsd}&minMarketCap=${testMinMarketCap}&maxMarketCap=${testMaxMarketCap}&includeRwaData=true&usePriceApiData=true`, ) .reply(200, sampleTrendingTokens) .persist(); const result = await getTrendingTokens({ chainIds: [testChainId], - sortBy, + sort, minLiquidity: testMinLiquidity, minVolume24hUsd: testMinVolume24hUsd, maxVolume24hUsd: testMaxVolume24hUsd, @@ -1292,7 +1292,7 @@ describe('Token service', () => { const result = await getTrendingTokens({ chainIds: [testChainId], - sortBy: 'h6_trending', + sort: 'h6_trending', minLiquidity: testMinLiquidity, minVolume24hUsd: testMinVolume, includeRwaData: false, @@ -1300,6 +1300,23 @@ describe('Token service', () => { }); expect(result).toStrictEqual(sampleTrendingTokensWithSecurityData); }); + + it('passes unknown query params through to the URL', async () => { + const testChainId = 'eip155:1'; + + nock(TOKEN_END_POINT_API) + .get( + `/v3/tokens/trending?chainIds=${encodeURIComponent(testChainId)}&includeRwaData=true&usePriceApiData=true&vsCurrency=eur`, + ) + .reply(200, sampleTrendingTokens) + .persist(); + + const result = await getTrendingTokens({ + chainIds: [testChainId], + vsCurrency: 'eur', + }); + expect(result).toStrictEqual(sampleTrendingTokens); + }); }); describe('searchTokens with includeTokenSecurityData', () => { diff --git a/packages/assets-controllers/src/token-service.ts b/packages/assets-controllers/src/token-service.ts index 02464f1556..1b49ccd656 100644 --- a/packages/assets-controllers/src/token-service.ts +++ b/packages/assets-controllers/src/token-service.ts @@ -138,24 +138,14 @@ function getTokenAssetsURL(options: { } /** - * Get the trending tokens URL for the given networks and search query. + * Shared query-parameter type for the v3 trending tokens endpoint. * - * @param options - Options for getting trending tokens. - * @param options.chainIds - Array of CAIP format chain IDs (e.g., ['eip155:1', 'eip155:137', 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp']). - * @param options.sort - The sort field. - * @param options.minLiquidity - The minimum liquidity. - * @param options.minVolume24hUsd - The minimum volume 24h in USD. - * @param options.maxVolume24hUsd - The maximum volume 24h in USD. - * @param options.minMarketCap - The minimum market cap. - * @param options.maxMarketCap - The maximum market cap. - * @param options.excludeLabels - Array of labels to exclude (e.g., ['stable_coin', 'blue_chip']). - * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to false). - * @param options.usePriceApiData - Optional flag to use price API data in the results (defaults to false). - * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). - * @returns The trending tokens URL. + * Known parameters are explicitly typed for autocomplete and documentation. + * The index signature allows new API parameters to pass through without + * requiring a core release — callers can add any additional key/value and + * it will be forwarded as a query parameter. */ -function getTrendingTokensURL(options: { - chainIds: CaipChainId[]; +export type TrendingTokensQueryParams = { sort?: SortTrendingBy; minLiquidity?: number; minVolume24hUsd?: number; @@ -166,11 +156,21 @@ function getTrendingTokensURL(options: { includeRwaData?: boolean; usePriceApiData?: boolean; includeTokenSecurityData?: boolean; -}): string { + [key: string]: string | number | boolean | string[] | undefined; +}; + +/** + * Get the trending tokens URL for the given networks and search query. + * + * @param options - Options bag: `chainIds` (required) plus any query params. + * @returns The trending tokens URL. + */ +function getTrendingTokensURL( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): string { const encodedChainIds = options.chainIds .map((id) => encodeURIComponent(id)) .join(','); - // Add the rest of query params if they are defined const queryParams = new URLSearchParams(); const { chainIds, excludeLabels, ...rest } = options; Object.entries(rest).forEach(([key, value]) => { @@ -391,46 +391,20 @@ export type TrendingAsset = { /** * Get the trending tokens for the given chains. * - * @param options - Options for getting trending tokens. - * @param options.chainIds - The chains to get the trending tokens for. - * @param options.sortBy - The sort by field. - * @param options.minLiquidity - The minimum liquidity. - * @param options.minVolume24hUsd - The minimum volume 24h in USD. - * @param options.maxVolume24hUsd - The maximum volume 24h in USD. - * @param options.minMarketCap - The minimum market cap. - * @param options.maxMarketCap - The maximum market cap. - * @param options.excludeLabels - Array of labels to exclude (e.g., ['stable_coin', 'blue_chip']). - * @param options.includeRwaData - Optional flag to include RWA data in the results (defaults to true). - * @param options.usePriceApiData - Optional flag to use price API data in the results (defaults to true). - * @param options.includeTokenSecurityData - Optional flag to include token security data in the results (defaults to false). + * Accepts all known query parameters plus any additional ones via the + * index signature on {@link TrendingTokensQueryParams}. New API parameters + * can be passed without updating this function. + * + * @param options - Options bag: `chainIds` (required) plus any query params + * supported by the v3 trending endpoint. * @returns The trending tokens. * @throws Will throw if the request fails. */ -export async function getTrendingTokens({ - chainIds, - sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - excludeLabels, - includeRwaData = true, - usePriceApiData = true, - includeTokenSecurityData, -}: { - chainIds: CaipChainId[]; - sortBy?: SortTrendingBy; - minLiquidity?: number; - minVolume24hUsd?: number; - maxVolume24hUsd?: number; - minMarketCap?: number; - maxMarketCap?: number; - excludeLabels?: string[]; - includeRwaData?: boolean; - usePriceApiData?: boolean; - includeTokenSecurityData?: boolean; -}): Promise { +export async function getTrendingTokens( + options: { chainIds: CaipChainId[] } & TrendingTokensQueryParams, +): Promise { + const { chainIds, ...rest } = options; + if (chainIds.length === 0) { console.error('No chains provided'); return []; @@ -438,16 +412,9 @@ export async function getTrendingTokens({ const trendingTokensURL = getTrendingTokensURL({ chainIds, - sort: sortBy, - minLiquidity, - minVolume24hUsd, - maxVolume24hUsd, - minMarketCap, - maxMarketCap, - excludeLabels, - includeRwaData, - usePriceApiData, - includeTokenSecurityData, + ...rest, + includeRwaData: rest.includeRwaData ?? true, + usePriceApiData: rest.usePriceApiData ?? true, }); try { From e413134cb0d23e3fd7b9d0d4b9b0f9f96095c515 Mon Sep 17 00:00:00 2001 From: Charly Chevalier Date: Tue, 12 May 2026 10:49:57 +0200 Subject: [PATCH 03/12] feat(account-tree-controller): add `:accountGroup{Created,Updated,Removed}` (#8766) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation Adding similar events than `:multichainAccountGroup{Created,Updated}`. Those events will be used by the `SnapAccountService`. ## References N/A ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Adds new post-`init` messenger events and tracking logic around account add/remove and group metadata mutations; risk is mainly around event ordering/duplication and downstream consumers relying on the new signals. > > **Overview** > Adds **three new messenger events**—`AccountTreeController:accountGroupCreated`, `:accountGroupUpdated`, and `:accountGroupRemoved`—to let consumers react to account-group lifecycle changes *after* `init`/`reinit` (no events during initialization). > > Updates `AccountTreeController` to track which groups were created/updated/removed during `AccountsController:accountsAdded` and `:accountsRemoved` batches (avoiding double `created`+`updated` in the same batch), publishes the new events after the existing `:accountTreeChange`, and emits `:accountGroupUpdated` on `setAccountGroupName`/`setAccountGroupPinned`/`setAccountGroupHidden`. > > Exports the new event types from `types.ts`/`index.ts`, adds a comprehensive test suite for the new event semantics, and updates the package changelog and eslint suppression counts accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f2ce76ba2e6aed8875f7b02cb47e12bbcd69148b. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- eslint-suppressions.json | 2 +- packages/account-tree-controller/CHANGELOG.md | 5 + .../src/AccountTreeController.test.ts | 308 ++++++++++++++++++ .../src/AccountTreeController.ts | 112 ++++++- packages/account-tree-controller/src/index.ts | 3 + packages/account-tree-controller/src/types.ts | 35 +- 6 files changed, 453 insertions(+), 12 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 7c61892367..62d7af273e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -12,7 +12,7 @@ }, "packages/account-tree-controller/src/AccountTreeController.ts": { "@typescript-eslint/explicit-function-return-type": { - "count": 8 + "count": 7 }, "@typescript-eslint/prefer-nullish-coalescing": { "count": 1 diff --git a/packages/account-tree-controller/CHANGELOG.md b/packages/account-tree-controller/CHANGELOG.md index b5e4965c01..8eef03ff2d 100644 --- a/packages/account-tree-controller/CHANGELOG.md +++ b/packages/account-tree-controller/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `AccountTreeController:accountGroup{Created,Updated,Removed}` events ([#8766](https://github.com/MetaMask/core/pull/8766)) + - None of these events fire during `init`/`reinit`, consumers should bootstrap from `:getState` or `:accountTreeChange`. + ### Changed - Bump `@metamask/accounts-controller` from `^38.0.0` to `^38.1.0` ([#8755](https://github.com/MetaMask/core/pull/8755)) diff --git a/packages/account-tree-controller/src/AccountTreeController.test.ts b/packages/account-tree-controller/src/AccountTreeController.test.ts index 71db24735f..dfdf7bfa5e 100644 --- a/packages/account-tree-controller/src/AccountTreeController.test.ts +++ b/packages/account-tree-controller/src/AccountTreeController.test.ts @@ -4740,6 +4740,314 @@ describe('AccountTreeController', () => { expect(selectedAccountGroupChangeListener).not.toHaveBeenCalled(); }); + + it('does NOT emit accountGroupCreated or accountGroupUpdated during init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + + expect(createdListener).not.toHaveBeenCalled(); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupCreated when a new group is added post-init', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_HD_ACCOUNT_2 }, + ]); + + const newWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const newGroupId = toMultichainAccountGroupId( + newWalletId, + MOCK_HD_ACCOUNT_2.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[newWalletId].groups[newGroupId]; + + expect(createdListener).toHaveBeenCalledTimes(1); + expect(createdListener).toHaveBeenCalledWith(expectedGroup); + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is added to an existing group', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const createdListener = jest.fn(); + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupCreated', + createdListener, + ); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsAdded', [ + { ...MOCK_TRX_ACCOUNT_1 }, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(createdListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when an account is removed but the group remains', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + }); + + it('does NOT emit accountGroupUpdated when a removed account causes the group to be pruned', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(updatedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupRemoved when the last account of a group is removed', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_SNAP_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1, MOCK_HD_KEYRING_2], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const removedWalletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_2.metadata.id, + ); + const removedGroupId = toMultichainAccountGroupId( + removedWalletId, + MOCK_SNAP_ACCOUNT_1.options.entropy.groupIndex, + ); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_SNAP_ACCOUNT_1.id, + ]); + + expect(removedListener).toHaveBeenCalledTimes(1); + expect(removedListener).toHaveBeenCalledWith(removedGroupId); + }); + + it('does NOT emit accountGroupRemoved when the group still has accounts', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1, MOCK_TRX_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const removedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupRemoved', + removedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + messenger.publish('AccountsController:accountsRemoved', [ + MOCK_TRX_ACCOUNT_1.id, + ]); + + expect(removedListener).not.toHaveBeenCalled(); + }); + + it('emits accountGroupUpdated when setAccountGroupName is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupName(groupId, 'Renamed Group'); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.name).toBe('Renamed Group'); + }); + + it('emits accountGroupUpdated when setAccountGroupPinned is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupPinned(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.pinned).toBe(true); + }); + + it('emits accountGroupUpdated when setAccountGroupHidden is called', () => { + const { controller, messenger } = setup({ + accounts: [MOCK_HD_ACCOUNT_1], + keyrings: [MOCK_HD_KEYRING_1], + }); + + const updatedListener = jest.fn(); + messenger.subscribe( + 'AccountTreeController:accountGroupUpdated', + updatedListener, + ); + + controller.init(); + jest.clearAllMocks(); + + const walletId = toMultichainAccountWalletId( + MOCK_HD_KEYRING_1.metadata.id, + ); + const groupId = toMultichainAccountGroupId( + walletId, + MOCK_HD_ACCOUNT_1.options.entropy.groupIndex, + ); + + controller.setAccountGroupHidden(groupId, true); + + const expectedGroup = + controller.state.accountTree.wallets[walletId].groups[groupId]; + + expect(updatedListener).toHaveBeenCalledTimes(1); + expect(updatedListener).toHaveBeenCalledWith(expectedGroup); + expect(expectedGroup.metadata.hidden).toBe(true); + }); }); describe('syncWithUserStorage', () => { diff --git a/packages/account-tree-controller/src/AccountTreeController.ts b/packages/account-tree-controller/src/AccountTreeController.ts index 211b16041f..5640662e9f 100644 --- a/packages/account-tree-controller/src/AccountTreeController.ts +++ b/packages/account-tree-controller/src/AccountTreeController.ts @@ -908,19 +908,32 @@ export class AccountTreeController extends BaseController< return; } + const createdGroups = new Map(); + const updatedGroups = new Map(); + this.update((state) => { for (const account of newAccounts) { - this.#insert(state.accountTree.wallets, account); + const { walletId, groupId, created } = this.#insert( + state.accountTree.wallets, + account, + ); - const context = this.#accountIdToContext.get(account.id); - if (context) { - const { walletId, groupId } = context; + if (created) { + createdGroups.set(groupId, walletId); + } else if (!createdGroups.has(groupId)) { + // ^ We check that the group has not been created in this same batch before adding it to the `updatedGroups` + // map, to avoid sending both created and updated events for the same group: + // - Account 1 + Account 2 + Account 3 + // - Account 1 and 3 belong to the same group + // - Account 1 will create the group + // - Account 3 will update the group (but we only want to send a created event, not an updated one) + updatedGroups.set(groupId, walletId); + } - const wallet = state.accountTree.wallets[walletId]; - if (wallet) { - this.#applyAccountWalletMetadata(state, walletId); - this.#applyAccountGroupMetadata(state, walletId, groupId); - } + const wallet = state.accountTree.wallets[walletId]; + if (wallet) { + this.#applyAccountWalletMetadata(state, walletId); + this.#applyAccountGroupMetadata(state, walletId, groupId); } } }); @@ -929,6 +942,13 @@ export class AccountTreeController extends BaseController< `${controllerName}:accountTreeChange`, this.state.accountTree, ); + + for (const [groupId, walletId] of createdGroups) { + this.#publishAccountGroupCreated(walletId, groupId); + } + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } } /** @@ -958,6 +978,8 @@ export class AccountTreeController extends BaseController< } const previousSelectedAccountGroup = this.state.selectedAccountGroup; + const updatedGroups = new Map(); + const removedGroups = new Set(); this.update((state) => { for (const { id: accountId, context } of knownAccounts) { @@ -981,6 +1003,12 @@ export class AccountTreeController extends BaseController< } if (accounts.length === 0) { this.#pruneEmptyGroupAndWallet(state, walletId, groupId); + + // If the group gets pruned, we should not consider it as updated. + updatedGroups.delete(groupId); + removedGroups.add(groupId); + } else { + updatedGroups.set(groupId, walletId); } } } @@ -996,6 +1024,13 @@ export class AccountTreeController extends BaseController< this.state.accountTree, ); + for (const [groupId, walletId] of updatedGroups) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + for (const groupId of removedGroups) { + this.#publishAccountGroupRemoved(groupId); + } + const newSelectedAccountGroup = this.state.selectedAccountGroup; if (newSelectedAccountGroup !== previousSelectedAccountGroup) { this.messenger.publish( @@ -1039,6 +1074,49 @@ export class AccountTreeController extends BaseController< return state; } + /** + * Publishes the `:accountGroupCreated` event for a newly added group. + * No-op if the group is not in state (defensive). + * + * @param walletId - The parent wallet ID. + * @param groupId - The newly created group's ID. + */ + #publishAccountGroupCreated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupCreated`, group); + } + } + + /** + * Publishes the `:accountGroupUpdated` event for an existing group. + * No-op if the group is not in state (e.g. it was pruned). + * + * @param walletId - The parent wallet ID. + * @param groupId - The updated group's ID. + */ + #publishAccountGroupUpdated( + walletId: AccountWalletId, + groupId: AccountGroupId, + ): void { + const group = this.state.accountTree.wallets[walletId]?.groups[groupId]; + if (group) { + this.messenger.publish(`${controllerName}:accountGroupUpdated`, group); + } + } + + /** + * Publishes the `:accountGroupRemoved` event for a pruned group. + * + * @param groupId - The removed group's ID. + */ + #publishAccountGroupRemoved(groupId: AccountGroupId): void { + this.messenger.publish(`${controllerName}:accountGroupRemoved`, groupId); + } + /** * Insert an account inside an account tree. * @@ -1048,11 +1126,12 @@ export class AccountTreeController extends BaseController< * * @param wallets - Account tree. * @param account - The account to be inserted. + * @returns The wallet ID, group ID, and whether the group has been created or not. */ #insert( wallets: AccountTreeControllerState['accountTree']['wallets'], account: InternalAccount, - ) { + ): { walletId: AccountWalletId; groupId: AccountGroupId; created: boolean } { const result = this.#getEntropyRule().match(account) ?? this.#getSnapRule().match(account) ?? @@ -1086,6 +1165,7 @@ export class AccountTreeController extends BaseController< let group = wallet.groups[groupId]; const { type, id } = account; const sortOrder = ACCOUNT_TYPE_TO_SORT_ORDER[type]; + const created = !group; if (!group) { log(`[${walletId}] Add new group: [${groupId}]`); @@ -1143,6 +1223,8 @@ export class AccountTreeController extends BaseController< groupId: group.id, sortOrder, }); + + return { walletId: wallet.id, groupId: group.id, created }; } /** @@ -1526,6 +1608,8 @@ export class AccountTreeController extends BaseController< finalName; }); + this.#publishAccountGroupUpdated(walletId, groupId); + // Trigger atomic sync for group rename (only for groups from entropy wallets) if (wallet.type === AccountWalletType.Entropy) { this.#backupAndSyncService.enqueueSingleGroupSync(groupId); @@ -1596,6 +1680,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group pinning (only for groups from entropy wallets) if ( walletId && @@ -1638,6 +1726,10 @@ export class AccountTreeController extends BaseController< } }); + if (walletId) { + this.#publishAccountGroupUpdated(walletId, groupId); + } + // Trigger atomic sync for group hiding (only for groups from entropy wallets) if ( walletId && diff --git a/packages/account-tree-controller/src/index.ts b/packages/account-tree-controller/src/index.ts index ab9fdcd59f..28b2f17f64 100644 --- a/packages/account-tree-controller/src/index.ts +++ b/packages/account-tree-controller/src/index.ts @@ -14,6 +14,9 @@ export type { AccountTreeControllerStateChangeEvent, AccountTreeControllerAccountTreeChangeEvent, AccountTreeControllerSelectedAccountGroupChangeEvent, + AccountTreeControllerAccountGroupCreatedEvent, + AccountTreeControllerAccountGroupUpdatedEvent, + AccountTreeControllerAccountGroupRemovedEvent, AccountTreeControllerEvents, AccountTreeControllerMessenger, } from './types'; diff --git a/packages/account-tree-controller/src/types.ts b/packages/account-tree-controller/src/types.ts index 6b0b91d096..e7a6657139 100644 --- a/packages/account-tree-controller/src/types.ts +++ b/packages/account-tree-controller/src/types.ts @@ -123,6 +123,36 @@ export type AccountTreeControllerSelectedAccountGroupChangeEvent = { payload: [AccountGroupId | '', AccountGroupId | '']; }; +/** + * Represents the `AccountTreeController:accountGroupCreated` event. + * This event is emitted when a new account group is added to the tree + * after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupCreatedEvent = { + type: `${typeof controllerName}:accountGroupCreated`; + payload: [AccountGroupObject]; +}; + +/** + * Represents the `AccountTreeController:accountGroupUpdated` event. + * This event is emitted when an existing account group's metadata or + * membership changes after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupUpdatedEvent = { + type: `${typeof controllerName}:accountGroupUpdated`; + payload: [AccountGroupObject]; +}; + +/** + * Represents the `AccountTreeController:accountGroupRemoved` event. + * This event is emitted when an account group is pruned from the tree + * (its last account was removed) after the controller has been initialized. + */ +export type AccountTreeControllerAccountGroupRemovedEvent = { + type: `${typeof controllerName}:accountGroupRemoved`; + payload: [AccountGroupId]; +}; + export type AllowedEvents = | AccountsControllerAccountsAddedEvent | AccountsControllerAccountsRemovedEvent @@ -133,7 +163,10 @@ export type AllowedEvents = export type AccountTreeControllerEvents = | AccountTreeControllerStateChangeEvent | AccountTreeControllerAccountTreeChangeEvent - | AccountTreeControllerSelectedAccountGroupChangeEvent; + | AccountTreeControllerSelectedAccountGroupChangeEvent + | AccountTreeControllerAccountGroupCreatedEvent + | AccountTreeControllerAccountGroupUpdatedEvent + | AccountTreeControllerAccountGroupRemovedEvent; export type AccountTreeControllerMessenger = Messenger< typeof controllerName, From b2e132cfc2a813fc94f139a80f1e0193c1036899 Mon Sep 17 00:00:00 2001 From: Daniel <80175477+dan437@users.noreply.github.com> Date: Tue, 12 May 2026 11:26:44 +0200 Subject: [PATCH 04/12] fix(transaction-pay-controller, transaction-controller): support Predict same-chain withdraw on swap-only Relay routes (#8735) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation Since the Polymarket pUSD migration on April 28 2026, Predict same-chain withdraws on Polygon (e.g. pUSD → USDC) sometimes fail with `transaction gas limit too high (cap: 33554432, tx: 42121000)`. Two interacting bugs: 1. **`transaction-pay-controller`** — `calculateSourceNetworkCost` always uses `fromOverride = Safe proxy` for Predict withdraws. That works for deposit-style Relay routes but breaks for the swap-aggregator routes that same-chain destinations now use post-migration: DEX aggregators reject contract callers (anti-MEV `msg.sender == tx.origin` checks etc.), so the swap step reverts in simulation. 2. **`transaction-controller`** — `estimateGasBatch`'s 7702 path always calls `estimateGas`, even when every batch entry already has a `gas` value. The non-7702 path has had an `allTransactionsHaveGas` short-circuit since [#7405](https://github.com/MetaMask/core/pull/7405); the 7702 path was missed. When TPC tries the EOA `from`, the swap sub-call reverts (EOA has no source token until the prepended `Safe.execTransaction` sub-call runs mid-batch), the whole 7702 batch falls back to ~block-gas-limit × buffer ≈ 42M, and Polygon's per-tx `gascap` rejects it. ### Fix - **TPC** `relay-quotes.ts`: gate `fromOverride = Safe proxy` on the route having a `deposit` step. Same-chain swap routes use the relay params' EOA `from` instead. Gas-fee-token lookup keeps using the Safe proxy (gated on `isPredictWithdraw && refundTo`) so the gasless flow still works for users with 0 POL. - **TPC** `constants.ts`: add `POLYGON_PUSD_ADDRESS` and include it in `STABLECOINS[Polygon]` for correct stablecoin display. - **TC** `gas.ts`: mirror the existing non-7702 short-circuit in the 7702 branch — when every transaction has a provided `gas`, sum them and skip simulation. Verified end-to-end on mobile (yalc-linked): Polygon pUSD → USDC withdraw produces a sane fee both with POL and gasless (paid in pUSD). BNB cross-chain unchanged. ## References N/A ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Changes gas estimation behavior for EIP-7702 batches and alters Relay quote simulation `from` selection for Predict withdraws; mistakes could impact fee estimation and transaction success for these flows. > > **Overview** > Fixes Predict same-chain withdraws on swap-only Relay routes by **gating the Safe-proxy `from` override** to only routes that include a `deposit` step, while still using the Safe proxy for gas-fee-token eligibility checks. > > Updates `estimateGasBatch`’s EIP-7702 path to **fallback to summed caller-provided per-tx `gas` limits when node simulation fails** (instead of block-gas-limit fallback), via a new `getProvidedBatchGasLimits` helper and added test coverage. > > Adds `POLYGON_PUSD_ADDRESS` and includes it in Polygon `STABLECOINS` for stablecoin display/fiat-rate handling, with new constants tests. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 8c1c30aea2c53c0aec05ae516fc0d987f5f814f3. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- packages/transaction-controller/CHANGELOG.md | 3 + .../src/utils/gas.test.ts | 153 ++++++++++++++++++ .../transaction-controller/src/utils/gas.ts | 73 +++++++-- .../transaction-pay-controller/CHANGELOG.md | 10 ++ .../src/constants.test.ts | 27 ++++ .../src/constants.ts | 8 +- .../src/strategy/relay/relay-quotes.test.ts | 89 +++++++++- .../src/strategy/relay/relay-quotes.ts | 19 ++- 8 files changed, 364 insertions(+), 18 deletions(-) create mode 100644 packages/transaction-pay-controller/src/constants.test.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 2ee6bdaba2..3c61b2ee26 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- `estimateGasBatch` now falls back to the sum of per-tx `gas` values in the EIP-7702 path when node simulation fails, instead of returning the block-gas-limit fallback ([#8735](https://github.com/MetaMask/core/pull/8735)) + - Real 7702 simulation is still preferred when it succeeds (the bundled call has no per-tx intrinsic gas cost so the estimate is typically tighter than summing per-tx limits). + - Required for callers that submit batches whose individual sub-calls cannot be simulated standalone, for example predict-withdraw, where the batch's first sub-call (`Safe.execTransaction`) provides source-token balance to subsequent sub-calls (approve + swap), and simulating the relay-only sub-calls in isolation reverts because the EOA has no balance until the Safe sub-call has run. ## [65.3.0] diff --git a/packages/transaction-controller/src/utils/gas.test.ts b/packages/transaction-controller/src/utils/gas.test.ts index 89af467dfd..b17f6e07df 100644 --- a/packages/transaction-controller/src/utils/gas.test.ts +++ b/packages/transaction-controller/src/utils/gas.test.ts @@ -23,6 +23,7 @@ import { addGasBuffer, estimateGas, estimateGasBatch, + getProvidedBatchGasLimits, updateGas, FIXED_GAS, DEFAULT_GAS_MULTIPLIER, @@ -1325,6 +1326,116 @@ describe('gas', () => { }); }); + it('prefers 7702 simulated gas over provided gas when simulation succeeds', async () => { + // The bundled 7702 call has no per-tx intrinsic gas cost so the + // simulated estimate is typically lower than the sum of provided per-tx + // limits — prefer it when available. + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasResponse: toHex(GAS_MOCK), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: GAS_MOCK, + gasLimits: [GAS_MOCK], + }); + }); + + it('falls back to provided gas in 7702 path when simulation fails', async () => { + // Callers that submit batches whose individual sub-calls cannot be + // simulated standalone (e.g. predict-withdraw, where the batch's first + // sub-call provides token balance to the rest) rely on this fallback — + // otherwise simulation reverts and falls back to the block gas limit. + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: true, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasError: new Error('execution reverted'), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [521000], + }); + }); + + it('preserves requiresAuthorizationList when 7702 fallback fires for upgrade-required account', async () => { + const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ + { + chainId: CHAIN_ID_MOCK, + isSupported: false, + upgradeContractAddress: UPGRADE_CONTRACT_ADDRESS_MOCK, + }, + ]); + + generateEIP7702BatchTransactionMock.mockReturnValue({ + to: TO_MOCK, + data: DATA_MOCK, + } as BatchTransactionParams); + + mockQuery({ + getBlockByNumberResponse: { gasLimit: toHex(BLOCK_GAS_LIMIT_MOCK) }, + estimateGasError: new Error('execution reverted'), + }); + + const result = await estimateGasBatch({ + networkClientId: NETWORK_CLIENT_ID_MOCK, + from: FROM_MOCK, + getSimulationConfig: GET_SIMULATION_CONFIG_MOCK, + isAtomicBatchSupported: isAtomicBatchSupportedMock, + messenger: MESSENGER_MOCK, + transactions: BATCH_TX_PARAMS_WITH_GAS_MOCK, + }); + + expect(result).toStrictEqual({ + totalGasLimit: 521000, + gasLimits: [521000], + requiresAuthorizationList: true, + }); + }); + it('throws error when upgrade contract address not found', async () => { const isAtomicBatchSupportedMock = jest.fn().mockResolvedValue([ { @@ -1490,6 +1601,48 @@ describe('gas', () => { }); }); + describe('getProvidedBatchGasLimits', () => { + it('returns parsed limits + sum when every transaction has a gas value', () => { + expect( + getProvidedBatchGasLimits(BATCH_TX_PARAMS_WITH_GAS_MOCK), + ).toStrictEqual({ + gasLimits: [21000, 500000], + totalGasLimit: 521000, + }); + }); + + it('returns undefined when none of the transactions have gas', () => { + expect(getProvidedBatchGasLimits(BATCH_TX_PARAMS_MOCK)).toBeUndefined(); + }); + + it('returns undefined when only some transactions have gas', () => { + const mixed = [BATCH_TX_PARAMS_WITH_GAS_MOCK[0], BATCH_TX_PARAMS_MOCK[0]]; + expect(getProvidedBatchGasLimits(mixed)).toBeUndefined(); + }); + + it('parses hex gas values correctly', () => { + const txWithHexGas = [ + { ...BATCH_TX_PARAMS_MOCK[0], gas: '0x5208' as Hex }, + { ...BATCH_TX_PARAMS_MOCK[1], gas: '0x7a120' as Hex }, + ]; + expect(getProvidedBatchGasLimits(txWithHexGas)).toStrictEqual({ + gasLimits: [21000, 500000], + totalGasLimit: 521000, + }); + }); + + it('returns zero-length result for an empty batch', () => { + // `every` on empty array returns true, so the function returns a valid + // (but empty) result rather than `undefined`. Callers of `estimateGasBatch` + // always pass at least one transaction, so this is documenting current + // behaviour rather than a guarantee. + expect(getProvidedBatchGasLimits([])).toStrictEqual({ + gasLimits: [], + totalGasLimit: 0, + }); + }); + }); + describe('simulateGasBatch', () => { it('returns the total gas limit as a hex string', async () => { simulateTransactionsMock.mockResolvedValueOnce( diff --git a/packages/transaction-controller/src/utils/gas.ts b/packages/transaction-controller/src/utils/gas.ts index 4bf791ccc0..9cf9e7bbf0 100644 --- a/packages/transaction-controller/src/utils/gas.ts +++ b/packages/transaction-controller/src/utils/gas.ts @@ -208,6 +208,39 @@ export async function estimateGas({ }; } +/** + * Sum caller-provided gas limits across a batch. + * + * If every transaction in the batch already has a `gas` value, returns the + * parsed per-tx limits and their sum. Otherwise returns `undefined`. + * + * Used by `estimateGasBatch`: + * - non-7702 path: short-circuits simulation entirely when present. + * - EIP-7702 path: used as a fallback when simulation fails — required for + * callers that submit batches whose individual sub-calls cannot be simulated + * standalone (e.g. predict-withdraw, where the batch's first sub-call + * provides source-token balance to subsequent sub-calls). When 7702 + * simulation succeeds it is preferred since the bundled call has no per-tx + * intrinsic gas cost and produces a tighter estimate. + * + * @param transactions - Batch transactions to inspect. + * @returns Parsed gas limits and total when every transaction has gas; otherwise `undefined`. + */ +export function getProvidedBatchGasLimits( + transactions: BatchTransactionParams[], +): { gasLimits: number[]; totalGasLimit: number } | undefined { + if (!transactions.every((transaction) => transaction.gas !== undefined)) { + return undefined; + } + + const gasLimits = transactions.map((transaction) => + new BigNumber(transaction.gas as Hex).toNumber(), + ); + const totalGasLimit = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); + + return { gasLimits, totalGasLimit }; +} + export async function estimateGasBatch({ from, getSimulationConfig, @@ -245,6 +278,8 @@ export async function estimateGasBatch({ } if (chainResult) { + const providedBatchGasLimits = getProvidedBatchGasLimits(transactions); + const authorizationList = isUpgradeRequired ? [{ address: chainResult.upgradeContractAddress as Hex }] : undefined; @@ -260,7 +295,12 @@ export async function estimateGasBatch({ type, }; - const { estimatedGas: gasLimitHex } = await estimateGas({ + // Prefer real EIP-7702 simulation when it succeeds — the bundled call has + // no per-tx intrinsic gas cost so the estimate is typically lower than + // summing per-tx provided limits. Fall back to the provided sum when the + // node-level simulation fails (e.g. predict-withdraw, where the batch's + // first sub-call provides source-token balance to subsequent sub-calls). + const { estimatedGas: gasLimitHex, simulationFails } = await estimateGas({ isSimulationEnabled: true, getSimulationConfig, messenger, @@ -268,6 +308,19 @@ export async function estimateGasBatch({ txParams: params, }); + if (simulationFails && providedBatchGasLimits) { + log( + 'EIP-7702 estimation failed, using batch parameter gas limits', + providedBatchGasLimits, + simulationFails, + ); + return { + gasLimits: [providedBatchGasLimits.totalGasLimit], + ...(isUpgradeRequired ? { requiresAuthorizationList: true } : {}), + totalGasLimit: providedBatchGasLimits.totalGasLimit, + }; + } + const totalGasLimit = new BigNumber(gasLimitHex).toNumber(); log('Estimated EIP-7702 gas limit', totalGasLimit); @@ -279,20 +332,10 @@ export async function estimateGasBatch({ }; } - const allTransactionsHaveGas = transactions.every( - (transaction) => transaction.gas !== undefined, - ); - - if (allTransactionsHaveGas) { - const gasLimits = transactions.map((transaction) => - new BigNumber(transaction.gas as Hex).toNumber(), - ); - - const total = gasLimits.reduce((acc, gasLimit) => acc + gasLimit, 0); - - log('Using batch parameter gas limits', { gasLimits, total }); - - return { totalGasLimit: total, gasLimits }; + const providedBatchGasLimits = getProvidedBatchGasLimits(transactions); + if (providedBatchGasLimits) { + log('Using batch parameter gas limits', providedBatchGasLimits); + return providedBatchGasLimits; } const { gasLimits: gasLimitsHex } = await simulateGasBatch({ diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 8be1d83951..f70e98602d 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) + +### Fixed + +- Predict same-chain withdraw quote no longer falls back to block-gas-limit (~30M+) on swap-only Relay routes ([#8735](https://github.com/MetaMask/core/pull/8735)) + - `fromOverride = Safe proxy` is now gated on the route having a `deposit` step. Same-chain destinations route through DEX swap aggregators that reject contract callers (anti-MEV `msg.sender == tx.origin` checks etc.) — for those, the relay params' EOA `from` is used so simulation succeeds. + - Gas-fee-token lookup still uses the Safe proxy for ALL Predict withdraws (gated on `isPredictWithdraw && refundTo`), preserving the gasless flow for users who hold pUSD in the Safe but no native POL on the EOA. + ## [22.3.0] ### Changed diff --git a/packages/transaction-pay-controller/src/constants.test.ts b/packages/transaction-pay-controller/src/constants.test.ts new file mode 100644 index 0000000000..5bb589f50c --- /dev/null +++ b/packages/transaction-pay-controller/src/constants.test.ts @@ -0,0 +1,27 @@ +import { + CHAIN_ID_POLYGON, + POLYGON_PUSD_ADDRESS, + POLYGON_USDCE_ADDRESS, + STABLECOINS, +} from './constants'; + +describe('STABLECOINS', () => { + it('includes both Polygon USDC.e and Polymarket pUSD as Polygon stablecoins', () => { + // pUSD is treated as a USD-pegged stablecoin so post-quote display logic + // uses currencyOut.amountFormatted (1:1 USD) instead of going through + // the USD-rate API. Without pUSD in this list, predict-withdraw quote + // displays would round-trip through fiat conversion needlessly. + const polygonStablecoins = STABLECOINS[CHAIN_ID_POLYGON]; + + expect(polygonStablecoins).toContain(POLYGON_USDCE_ADDRESS.toLowerCase()); + expect(polygonStablecoins).toContain(POLYGON_PUSD_ADDRESS.toLowerCase()); + }); + + it('lower-cases all stablecoin entries for case-insensitive lookup', () => { + for (const [, addresses] of Object.entries(STABLECOINS)) { + for (const address of addresses) { + expect(address).toBe(address.toLowerCase()); + } + } + }); +}); diff --git a/packages/transaction-pay-controller/src/constants.ts b/packages/transaction-pay-controller/src/constants.ts index c740bdbd6d..52e39b0efb 100644 --- a/packages/transaction-pay-controller/src/constants.ts +++ b/packages/transaction-pay-controller/src/constants.ts @@ -15,6 +15,9 @@ export const ARBITRUM_USDC_ADDRESS = export const POLYGON_USDCE_ADDRESS = '0x2791Bca1f2de4661ED88A30C99A7a9449Aa84174' as Hex; +export const POLYGON_PUSD_ADDRESS = + '0xC011a7E12a19f7B1f670d46F03B03f3342E82DFB' as Hex; + export const HYPERCORE_USDC_ADDRESS = '0x00000000000000000000000000000000'; export const HYPERCORE_USDC_DECIMALS = 8; @@ -38,7 +41,10 @@ export const STABLECOINS: Record = { '0x176211869ca2b568f2a7d4ee941e073a821ee1ff', // USDC '0xa219439258ca9da29e9cc4ce5596924745e12b93', // USDT ], - [CHAIN_ID_POLYGON]: [POLYGON_USDCE_ADDRESS.toLowerCase() as Hex], + [CHAIN_ID_POLYGON]: [ + POLYGON_USDCE_ADDRESS.toLowerCase() as Hex, + POLYGON_PUSD_ADDRESS.toLowerCase() as Hex, + ], [CHAIN_ID_HYPERCORE]: [HYPERCORE_USDC_ADDRESS], // USDC }; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts index f5d7c72a2f..337b829e1d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.test.ts @@ -83,7 +83,7 @@ const QUOTE_REQUEST_MOCK: QuoteRequest = { }; const STEP_MOCK: RelayTransactionStep = { - id: 'swap', + id: 'deposit', requestId: '0x1', kind: 'transaction', items: [ @@ -1538,6 +1538,93 @@ describe('Relay Quotes Utils', () => { ); }); + it('uses original (EOA) from for predictWithdraw post-quote when route has no deposit step', async () => { + // Same-chain swap routes (e.g. Polygon pUSD -> Polygon USDC) only emit + // `approve` + `swap` steps. Simulating from the Safe proxy reverts in + // the swap step because DEX aggregators reject contract callers, so the + // override must be skipped and the relay params' EOA `from` used instead. + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps = [ + { ...STEP_MOCK, id: 'approve' }, + { ...STEP_MOCK, id: 'swap' }, + ]; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 100000, + gasLimits: [50000, 50000], + }); + + const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; + + await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + refundTo: proxyAddress, + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(estimateGasBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: FROM_MOCK, + }), + ); + }); + + it('still uses Safe proxy for gas-fee-token lookup on swap-only predictWithdraw routes', async () => { + // The gas-fee-token lookup must always use the Safe proxy for Predict + // withdraws (because the source token lives in the Safe, not the EOA), + // even when the gas-estimation path falls back to the EOA `from`. + const quoteMock = cloneDeep(QUOTE_MOCK); + quoteMock.steps = [ + { ...STEP_MOCK, id: 'approve' }, + { ...STEP_MOCK, id: 'swap' }, + ]; + + successfulFetchMock.mockResolvedValue({ + ok: true, + json: async () => quoteMock, + } as never); + + estimateGasBatchMock.mockResolvedValue({ + totalGasLimit: 100000, + gasLimits: [50000, 50000], + }); + + getTokenBalanceMock.mockReturnValue('0'); + getGasFeeTokensMock.mockResolvedValue([GAS_FEE_TOKEN_MOCK]); + + const proxyAddress = '0xproxyAddress1234567890123456789012345678' as Hex; + + const result = await getRelayQuotes({ + messenger, + requests: [ + { + ...QUOTE_REQUEST_MOCK, + targetAmountMinimum: '0', + isPostQuote: true, + refundTo: proxyAddress, + }, + ], + transaction: PREDICT_WITHDRAW_TRANSACTION_MOCK, + }); + + expect(getGasFeeTokensMock).toHaveBeenCalledWith( + expect.objectContaining({ from: proxyAddress }), + ); + expect(result[0].fees.isSourceGasFeeToken).toBe(true); + }); + it('sets isSourceGasFeeToken for predictWithdraw post-quote when insufficient native balance', async () => { successfulFetchMock.mockResolvedValue({ ok: true, diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts index 14b95c089d..7d639eea2d 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-quotes.ts @@ -669,7 +669,17 @@ async function calculateSourceNetworkCost( const isPredictWithdraw = request.isPostQuote && isPredictWithdrawTransaction(transaction); - const fromOverride = isPredictWithdraw ? request.refundTo : undefined; + // `fromOverride = Safe proxy` is only valid for deposit-style Relay routes + // where the deposit contract reads the user's source-token balance directly. + // Same-chain destinations route through DEX swap aggregators that frequently + // reject contract callers (anti-MEV `msg.sender == tx.origin` checks, + // ERC777-style callback interfaces, native wrap/unwrap requiring caller + // native balance). Simulating those from the Safe proxy reverts and breaks + // gas estimation. For swap-only routes, fall back to the relay params' + // EOA `from` so simulation succeeds. + const hasDepositStep = quote.steps.some((step) => step.id === 'deposit'); + const useFromOverride = isPredictWithdraw && hasDepositStep; + const fromOverride = useFromOverride ? request.refundTo : undefined; const relayOnlyGas = await calculateSourceNetworkGasLimit( relayParams, @@ -745,6 +755,13 @@ async function calculateSourceNetworkCost( max: max.raw, }); + // Gas-fee-token lookup must use the Safe proxy for ALL Predict withdraws, + // not only deposit-style routes. The user's source token (pUSD) lives in + // the Safe; the EOA is empty until the Safe.execTransaction sub-call runs + // mid-batch. Querying the EOA for gas-fee-token availability would always + // return nothing and force users to hold POL. + // (`useFromOverride` only governs the gas-estimation `from` address, where + // swap-style routes need EOA because DEX routers reject contract callers.) if (isPredictWithdraw && request.refundTo) { log('Using proxy address for predict withdraw gas station simulation', { proxyAddress: request.refundTo, From ff1e9aa2fc0291a1a074edbe1f804015cdd4883a Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 12 May 2026 11:44:36 +0200 Subject: [PATCH 05/12] feat(analytics-controller): optional skipUUIDv4Check on platform adapter (#8543) ## Explanation Allow non-UUIDv4 analyticsId when adapter.skipUUIDv4Check is true, as this is needed in Extension, where IDs are not UUIDv4. ## References NA ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Relaxes `analyticsId` validation when `skipUUIDv4Check` is enabled on the platform adapter, which could allow inconsistent identifiers if misused but remains opt-in and still rejects empty IDs. > > **Overview** > Adds an optional `skipUUIDv4Check` flag to `AnalyticsPlatformAdapter` and threads it into `AnalyticsController` state validation so platforms can use non-UUIDv4 `analyticsId` values while still requiring a non-empty string. > > Updates the state validator and expands unit tests (controller + validator) to cover the new bypass behavior, and documents the change in the package changelog. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 9f6e59816de750dcc303fd1773fef1c7ad257abe. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- packages/analytics-controller/CHANGELOG.md | 4 +++ .../src/AnalyticsController.test.ts | 20 ++++++++++++ .../src/AnalyticsController.ts | 5 ++- .../src/AnalyticsPlatformAdapter.types.ts | 6 ++++ .../analyticsControllerStateValidator.test.ts | 31 +++++++++++++++++++ .../src/analyticsControllerStateValidator.ts | 7 ++++- 6 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 38b473c825..1078df2b0c 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Optional `skipUUIDv4Check` on `AnalyticsPlatformAdapter` to allow non-UUIDv4 `analyticsId` strings when constructing `AnalyticsController` ([#8543](https://github.com/MetaMask/core/pull/8543)) + ### Changed - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index e6c9a43a32..e012047a27 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -377,6 +377,26 @@ describe('AnalyticsController', () => { }).toThrow('Invalid analyticsId'); }); + it('accepts non-UUID analyticsId when adapter skipUUIDv4Check is true', async () => { + const analyticsId = 'not-a-uuid'; + const platformAdapter = { + ...createMockAdapter(), + skipUUIDv4Check: true, + }; + const { controller } = await setupController({ + state: { + optedIn: false, + analyticsId, + }, + platformAdapter, + }); + + expect(controller.state.analyticsId).toBe(analyticsId); + expect(platformAdapter.onSetupCompleted).toHaveBeenCalledWith( + analyticsId, + ); + }); + it('accepts different valid UUIDv4 values', async () => { const analyticsId1 = '11111111-1111-4111-8111-111111111111'; const analyticsId2 = '22222222-2222-4222-9222-222222222222'; diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 352b170247..8f87062be9 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -219,7 +219,10 @@ export class AnalyticsController extends BaseController< ...state, }; - validateAnalyticsControllerState(initialState); + validateAnalyticsControllerState( + initialState, + platformAdapter.skipUUIDv4Check === true, + ); super({ name: controllerName, diff --git a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts index 39d22ff592..78485ba6f4 100644 --- a/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts +++ b/packages/analytics-controller/src/AnalyticsPlatformAdapter.types.ts @@ -33,6 +33,12 @@ export type AnalyticsTrackingEvent = { * Implementations should handle platform-specific details (Segment SDK, etc.) */ export type AnalyticsPlatformAdapter = { + /** + * When `true`, the controller accepts any non-empty `analyticsId` string + * instead of requiring UUIDv4 format. Defaults to validation against UUIDv4 when omitted or `false`. + */ + skipUUIDv4Check?: boolean; + /** * Track an analytics event. * diff --git a/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts b/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts index b36dee3816..2e1dea9c96 100644 --- a/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts +++ b/packages/analytics-controller/src/analyticsControllerStateValidator.test.ts @@ -47,5 +47,36 @@ describe('analyticsControllerStateValidator', () => { expect(() => validateAnalyticsControllerState(state)).not.toThrow(); }); + + it('does not throw for non-UUID string when skipUUIDv4Check is true', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: 'not-a-uuid', + }; + + expect(() => validateAnalyticsControllerState(state, true)).not.toThrow(); + }); + + it('throws when analyticsId is not UUIDv4 and skipUUIDv4Check is false', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: 'not-a-uuid', + }; + + expect(() => validateAnalyticsControllerState(state, false)).toThrow( + 'Invalid analyticsId', + ); + }); + + it('throws when analyticsId is empty string even if skipUUIDv4Check is true', () => { + const state: AnalyticsControllerState = { + optedIn: false, + analyticsId: '', + }; + + expect(() => validateAnalyticsControllerState(state, true)).toThrow( + 'Invalid analyticsId', + ); + }); }); }); diff --git a/packages/analytics-controller/src/analyticsControllerStateValidator.ts b/packages/analytics-controller/src/analyticsControllerStateValidator.ts index 1713a9731b..f40773994d 100644 --- a/packages/analytics-controller/src/analyticsControllerStateValidator.ts +++ b/packages/analytics-controller/src/analyticsControllerStateValidator.ts @@ -24,12 +24,17 @@ export function isValidUUIDv4(value: string): boolean { * Validates that the analytics state has a valid UUIDv4 analyticsId. * * @param state - The analytics controller state to validate + * @param skipUUIDv4Check - When `true`, skips UUIDv4 format validation * @throws Error if analyticsId is missing or not a valid UUIDv4 */ export function validateAnalyticsControllerState( state: AnalyticsControllerState, + skipUUIDv4Check?: boolean, ): void { - if (!state.analyticsId || !isValidUUIDv4(state.analyticsId)) { + if ( + !state.analyticsId || + (skipUUIDv4Check !== true && !isValidUUIDv4(state.analyticsId)) + ) { throw new Error('Invalid analyticsId'); } } From d82c50d3f25a60169b7630079635ecb0f193ffe7 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 12 May 2026 10:54:40 +0100 Subject: [PATCH 06/12] feat: [MUSD-445] add build delegations step to money upgrade (#8621) ## Explanation In this PR we * Add the remaining steps required for the upgrade process * Update the chomp service so it does not retry 409 errors - since those indicate an error that isn't going to go away with a retry. This has been tested in the mobile client - and it currently reaches the final step of the upgrade process where we `POST https://chomp.dev-api.cx.metamask.io/v1/intent` - this currently returns a 500 error after a long delay. We're still investigating the cause of this, as there may be changes required to chomp. ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Introduces new delegation-signing/storage and intent-registration steps plus breaking `init()` and messenger-action requirements in `MoneyAccountUpgradeController`, which affects upgrade orchestration and on-chain/CHOMP interactions. Also changes `ChompApiService` retry behavior for 4xx responses, which could alter client error handling and backoff characteristics. > > **Overview** > **Completes the money account upgrade sequence** by adding `build-delegation` and `register-intents` steps after EIP-7702 authorization, including delegation creation/signing (via `DelegationController`), CHOMP verification, persistence to `AuthenticatedUserStorageService`, and CHOMP intent registration (skipping existing active intents and re-registering revoked ones). > > **Breaking API/config changes** in `MoneyAccountUpgradeController`: `init()` now takes `{ chainId, boringVaultAddress }`, resolves Delegation Framework contract/enforcer addresses from `@metamask/delegation-deployments` (throws if unsupported), and expands required messenger permissions to include storage, delegation signing, and CHOMP intent/delegation APIs. > > **Retry semantics change** in `@metamask/chomp-api-service`: default retry policy now *does not retry* most 4xx responses (except `429`), while continuing to retry `5xx` and non-HTTP errors; tests updated/added to assert the new behavior and allow overriding via `policyOptions`. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 0cf880a27fba144bcff98d9f59c88ab996e5344d. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Claude Opus 4.7 (1M context) --- README.md | 2 + packages/chomp-api-service/CHANGELOG.md | 4 + .../src/chomp-api-service.test.ts | 121 +++- .../src/chomp-api-service.ts | 32 +- .../CHANGELOG.md | 12 + .../jest.config.js | 4 +- .../jest.environment.js | 18 + .../package.json | 5 + .../src/MoneyAccountUpgradeController.test.ts | 179 ++++-- .../src/MoneyAccountUpgradeController.ts | 71 ++- .../src/index.ts | 2 +- .../src/steps/associate-address.test.ts | 72 +-- .../src/steps/build-delegations.test.ts | 579 ++++++++++++++++++ .../src/steps/build-delegations.ts | 204 ++++++ .../src/steps/delegation-matchers.ts | 32 + .../src/steps/eip-7702-authorization.test.ts | 46 ++ .../src/steps/eip-7702-authorization.ts | 38 +- .../src/steps/register-intents.test.ts | 509 +++++++++++++++ .../src/steps/register-intents.ts | 101 +++ .../src/steps/step.ts | 7 + .../src/types.ts | 29 +- .../tsconfig.build.json | 2 + .../tsconfig.json | 2 + yarn.lock | 42 +- 24 files changed, 1966 insertions(+), 147 deletions(-) create mode 100644 packages/money-account-upgrade-controller/jest.environment.js create mode 100644 packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/build-delegations.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/register-intents.test.ts create mode 100644 packages/money-account-upgrade-controller/src/steps/register-intents.ts diff --git a/README.md b/README.md index d2810cadd5..dd2325a539 100644 --- a/README.md +++ b/README.md @@ -380,8 +380,10 @@ linkStyle default opacity:0.5 money_account_controller --> base_controller; money_account_controller --> keyring_controller; money_account_controller --> messenger; + money_account_upgrade_controller --> authenticated_user_storage; money_account_upgrade_controller --> base_controller; money_account_upgrade_controller --> chomp_api_service; + money_account_upgrade_controller --> delegation_controller; money_account_upgrade_controller --> keyring_controller; money_account_upgrade_controller --> messenger; money_account_upgrade_controller --> network_controller; diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index b54f3dc871..b37660b4fe 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- `ChompApiService` no longer retries HTTP requests that fail with a 4xx response (other than 429), since those responses indicate the request itself is at fault and will not be resolved by re-issuing it. 5xx, 429, and non-HTTP errors (network/timeout) continue to be retried. Consumers can still override this by passing a `retryFilterPolicy` via `policyOptions`. ([#8621](https://github.com/MetaMask/core/pull/8621)) + ## [3.0.1] ### Changed diff --git a/packages/chomp-api-service/src/chomp-api-service.test.ts b/packages/chomp-api-service/src/chomp-api-service.test.ts index 921a685c93..0cab2c7e02 100644 --- a/packages/chomp-api-service/src/chomp-api-service.test.ts +++ b/packages/chomp-api-service/src/chomp-api-service.test.ts @@ -1,4 +1,4 @@ -import { DEFAULT_MAX_RETRIES } from '@metamask/controller-utils'; +import { DEFAULT_MAX_RETRIES, handleAll } from '@metamask/controller-utils'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, @@ -255,10 +255,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/intent/verify-delegation') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).post('/v1/intent/verify-delegation').reply(400); const { service } = createService(); await expect(service.verifyDelegation(delegationParams)).rejects.toThrow( @@ -322,10 +319,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/intent') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(409); + nock(BASE_URL).post('/v1/intent').reply(409); const { service } = createService(); await expect(service.createIntents(intentParams)).rejects.toThrow( @@ -432,10 +426,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .post('/v1/withdrawal') - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).post('/v1/withdrawal').reply(400); const { service } = createService(); await expect(service.createWithdrawal(withdrawalParams)).rejects.toThrow( @@ -509,11 +500,7 @@ describe('ChompApiService', () => { }); it('throws on non-OK status', async () => { - nock(BASE_URL) - .get('/v1/chomp') - .query({ chainId: '0xa4b1' }) - .times(DEFAULT_MAX_RETRIES + 1) - .reply(400); + nock(BASE_URL).get('/v1/chomp').query({ chainId: '0xa4b1' }).reply(400); const { service } = createService(); await expect(service.getServiceDetails(['0xa4b1'])).rejects.toThrow( @@ -533,6 +520,104 @@ describe('ChompApiService', () => { ); }); }); + + describe('retry policy', () => { + const upgradeParams = { + r: '0x1' as const, + s: '0x2' as const, + v: 27, + yParity: 0, + address: '0xabc' as const, + chainId: '1', + nonce: '0', + }; + + it('retries 5xx responses up to the default retry limit', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [500]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '500'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it.each([400, 401, 403, 404, 409, 422])( + 'does not retry %i responses', + async (status) => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [status]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + `POST /v1/account-upgrade failed with status '${status}'`, + ); + expect(attempts).toBe(1); + }, + ); + + it('retries 429 responses alongside 5xx (rate-limit is transient)', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [429]; + }); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '429'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + + it('retries non-HTTP errors (e.g. network failures)', async () => { + const scope = nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .replyWithError('network down'); + const { service } = createService(); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + 'network down', + ); + expect(scope.isDone()).toBe(true); + }); + + it('lets consumer-supplied policyOptions override the default retryFilterPolicy', async () => { + let attempts = 0; + nock(BASE_URL) + .post('/v1/account-upgrade') + .times(DEFAULT_MAX_RETRIES + 1) + .reply(() => { + attempts += 1; + return [409]; + }); + const { service } = createService({ + options: { policyOptions: { retryFilterPolicy: handleAll } }, + }); + + await expect(service.createUpgrade(upgradeParams)).rejects.toThrow( + "POST /v1/account-upgrade failed with status '409'", + ); + expect(attempts).toBe(DEFAULT_MAX_RETRIES + 1); + }); + }); }); /** diff --git a/packages/chomp-api-service/src/chomp-api-service.ts b/packages/chomp-api-service/src/chomp-api-service.ts index 87a94291dd..b04c683041 100644 --- a/packages/chomp-api-service/src/chomp-api-service.ts +++ b/packages/chomp-api-service/src/chomp-api-service.ts @@ -5,7 +5,7 @@ import type { DataServiceInvalidateQueriesAction, } from '@metamask/base-data-service'; import type { CreateServicePolicyOptions } from '@metamask/controller-utils'; -import { HttpError } from '@metamask/controller-utils'; +import { handleWhen, HttpError } from '@metamask/controller-utils'; import type { Messenger } from '@metamask/messenger'; import { array, @@ -223,6 +223,34 @@ const ServiceDetailsResponseStruct = type({ ), }); +// === RETRY POLICY === + +/** + * Determines whether an error from a CHOMP API call is worth retrying. + * + * 4xx responses (e.g. 409 "already exists", 400 validation, 401/403 auth) are + * caused by the request itself and will not be resolved by re-issuing the same + * request, so they bypass the retry loop. 429 is treated as transient and + * retried alongside 5xx server errors. Non-HTTP errors (network/timeout) fall + * through to the default "retry" behaviour. + * + * @param error - The error thrown by the query function. + * @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; +} + +const DEFAULT_POLICY_OPTIONS: CreateServicePolicyOptions = { + retryFilterPolicy: handleWhen(isRetryableError), +}; + // === SERVICE DEFINITION === /** @@ -262,7 +290,7 @@ export class ChompApiService extends BaseDataService< name: serviceName, messenger, queryClientConfig, - policyOptions, + policyOptions: { ...DEFAULT_POLICY_OPTIONS, ...policyOptions }, }); this.#baseUrl = baseUrl; diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index d3b4943766..35862153e4 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,10 +7,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add remaining steps in money account upgrade process ([#8621](https://github.com/MetaMask/core/pull/8621)) + ### Changed +- **BREAKING:** The controller messenger now requires access to six additional allowed actions: `AuthenticatedUserStorageService:listDelegations`, `AuthenticatedUserStorageService:createDelegation`, `ChompApiService:verifyDelegation`, `ChompApiService:getIntentsByAddress`, `ChompApiService:createIntents`, and `DelegationController:signDelegation`. Delegation signing is now delegated to `@metamask/delegation-controller` rather than calling `KeyringController:signTypedMessage` directly; consumers must instantiate `DelegationController` and update their messenger configuration accordingly. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- **BREAKING:** `init()` now takes a `{ chainId, boringVaultAddress }` object instead of an `InitConfig`. The EIP-7702 delegator implementation and caveat enforcer addresses are resolved from `@metamask/delegation-deployments` for the target chain; `init()` throws if the chain is not supported by Delegation Framework 1.3.0. The `InitConfig` type is no longer exported. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- Add `@metamask/authenticated-user-storage`, `@metamask/delegation-controller`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +### Fixed + +- Build-delegation step no longer emits a redundant duplicate `ValueLteEnforcer` caveat; the Delegation Framework treats both as equivalent, but the duplicate was inadvertently inherited from `@metamask/smart-accounts-kit`'s `erc20TransferAmount` scope helper. ([#8621](https://github.com/MetaMask/core/pull/8621)) +- EIP-7702 authorization step now treats a 409 response from `POST /v1/account-upgrade` as `already-done` instead of a fatal error, making the step retry-safe when a prior submission was accepted by CHOMP but has not yet been observed on-chain. ([#8621](https://github.com/MetaMask/core/pull/8621)) + ## [1.3.2] ### Changed diff --git a/packages/money-account-upgrade-controller/jest.config.js b/packages/money-account-upgrade-controller/jest.config.js index ca08413339..f5ba61687a 100644 --- a/packages/money-account-upgrade-controller/jest.config.js +++ b/packages/money-account-upgrade-controller/jest.config.js @@ -11,10 +11,7 @@ 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, @@ -23,4 +20,5 @@ module.exports = merge(baseConfig, { statements: 100, }, }, + testEnvironment: '/jest.environment.js', }); diff --git a/packages/money-account-upgrade-controller/jest.environment.js b/packages/money-account-upgrade-controller/jest.environment.js new file mode 100644 index 0000000000..a2ab37baeb --- /dev/null +++ b/packages/money-account-upgrade-controller/jest.environment.js @@ -0,0 +1,18 @@ +const { TestEnvironment } = require('jest-environment-node'); + +/** + * Some transitive dependencies rely on the Web Crypto API, which is not + * exposed as a global by jest-environment-node. + */ +class CustomTestEnvironment extends TestEnvironment { + async setup() { + await super.setup(); + if (typeof this.global.crypto === 'undefined') { + // Only used for testing. + // eslint-disable-next-line n/no-unsupported-features/node-builtins + this.global.crypto = require('crypto').webcrypto; + } + } +} + +module.exports = CustomTestEnvironment; diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 0e3b4c1fe4..802cbe10f2 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -53,8 +53,12 @@ "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { + "@metamask/authenticated-user-storage": "^1.0.1", "@metamask/base-controller": "^9.1.0", "@metamask/chomp-api-service": "^3.0.1", + "@metamask/delegation-controller": "^3.0.0", + "@metamask/delegation-core": "^2.0.0", + "@metamask/delegation-deployments": "^1.3.0", "@metamask/keyring-controller": "^25.5.0", "@metamask/messenger": "^1.2.0", "@metamask/network-controller": "^31.1.0", @@ -66,6 +70,7 @@ "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", "ts-jest": "^29.2.5", "tsx": "^4.20.5", "typedoc": "^0.25.13", diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts index 8504e2f001..6ce78bc3c6 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.test.ts @@ -1,51 +1,52 @@ +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace, MessengerActions, MessengerEvents, } from '@metamask/messenger'; +import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMessenger } from '.'; import { MoneyAccountUpgradeController } from '.'; -import type { UpgradeConfig } from './types'; -const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_CHAIN_ID = '0x1' as Hex; // mainnet, supported in delegation-deployments@1.3.0 +const UNSUPPORTED_CHAIN_ID = '0x539' as Hex; // 1337 — local dev, not in registry const MOCK_ACCOUNT_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; - -const MOCK_CONFIG: UpgradeConfig = { - delegateAddress: '0x1111111111111111111111111111111111111111' as Hex, - delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, - musdTokenAddress: '0x3333333333333333333333333333333333333333' as Hex, - vedaVaultAdapterAddress: '0x4444444444444444444444444444444444444444' as Hex, - erc20TransferAmountEnforcer: - '0x5555555555555555555555555555555555555555' as Hex, - redeemerEnforcer: '0x6666666666666666666666666666666666666666' as Hex, - valueLteEnforcer: '0x7777777777777777777777777777777777777777' as Hex, -}; - -const MOCK_INIT_CONFIG = { - delegatorImplAddress: MOCK_CONFIG.delegatorImplAddress, - musdTokenAddress: MOCK_CONFIG.musdTokenAddress, - redeemerEnforcer: MOCK_CONFIG.redeemerEnforcer, - valueLteEnforcer: MOCK_CONFIG.valueLteEnforcer, -}; +const MOCK_BORING_VAULT_ADDRESS = + '0xA20f97813014129E7609171d2D3AA3da5206259e' as Hex; + +// CHOMP-API-derived values. +const MOCK_DELEGATE_ADDRESS = + '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD_TOKEN_ADDRESS = + '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VEDA_VAULT_ADAPTER_ADDRESS = + '0x4444444444444444444444444444444444444444' as Hex; + +// Delegation Framework deployment for mainnet @ 1.3.0 — the controller resolves +// these from `@metamask/delegation-deployments` rather than accepting them via +// `init()`. We re-read from the same source here so the test does not drift if +// the deployment registry is bumped. +const MAINNET_CONTRACTS = + DELEGATOR_CONTRACTS['1.3.0'][hexToNumber(MOCK_CHAIN_ID)]; const MOCK_SERVICE_DETAILS_RESPONSE = { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [ { - tokenAddress: MOCK_CONFIG.erc20TransferAmountEnforcer, + tokenAddress: MOCK_MUSD_TOKEN_ADDRESS, tokenDecimals: 18, }, ], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'] as const, }, }, @@ -68,6 +69,12 @@ type Mocks = { findNetworkClientIdByChainId: jest.Mock; getNetworkClientById: jest.Mock; providerRequest: jest.Mock; + listDelegations: jest.Mock; + createDelegation: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; }; function setup(): { @@ -104,7 +111,7 @@ function setup(): { }), createUpgrade: jest.fn().mockResolvedValue({ signerAddress: MOCK_ACCOUNT_ADDRESS, - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', status: 'pending', @@ -118,6 +125,12 @@ function setup(): { provider: { request: providerRequest }, }), providerRequest, + listDelegations: jest.fn().mockResolvedValue([]), + createDelegation: jest.fn().mockResolvedValue(undefined), + signDelegation: jest.fn().mockResolvedValue(`0x${'cd'.repeat(65)}`), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), }; const rootMessenger = new Messenger({ @@ -152,6 +165,30 @@ function setup(): { 'NetworkController:getNetworkClientById', mocks.getNetworkClientById, ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ namespace: 'MoneyAccountUpgradeController', @@ -167,6 +204,12 @@ function setup(): { 'KeyringController:signEip7702Authorization', 'NetworkController:findNetworkClientIdByChainId', 'NetworkController:getNetworkClientById', + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', ], events: [], messenger, @@ -192,11 +235,50 @@ describe('MoneyAccountUpgradeController', () => { it('fetches service details and builds config', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect(mocks.getServiceDetails).toHaveBeenCalledWith([MOCK_CHAIN_ID]); }); + it('throws when the chain has no Delegation Framework deployment', async () => { + const { controller, mocks } = setup(); + + await expect( + controller.init({ + chainId: UNSUPPORTED_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), + ).rejects.toThrow( + `Delegation Framework 1.3.0 is not deployed on chain ${UNSUPPORTED_CHAIN_ID}`, + ); + expect(mocks.getServiceDetails).not.toHaveBeenCalled(); + }); + + it('uses the supplied boring vault address as the withdrawal-side delegation token', async () => { + const { controller, mocks } = setup(); + + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); + await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); + + // Both delegations were signed; the boring-vault address shows up in the + // ABI-encoded ERC20TransferAmount caveat terms of one of them. + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + const allCaveatTerms = mocks.verifyDelegation.mock.calls + .flatMap(([{ signedDelegation }]) => signedDelegation.caveats) + .map((caveat) => caveat.terms.toLowerCase()); + expect( + allCaveatTerms.some((terms) => + terms.includes(MOCK_BORING_VAULT_ADDRESS.toLowerCase().slice(2)), + ), + ).toBe(true); + }); + it('throws when the chain is not found in service details', async () => { const { controller, mocks } = setup(); @@ -206,7 +288,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `Chain ${MOCK_CHAIN_ID} not found in service details response`, ); @@ -219,14 +304,17 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: {}, }, }, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `vedaProtocol not found for chain ${MOCK_CHAIN_ID} in service details response`, ); @@ -239,11 +327,11 @@ describe('MoneyAccountUpgradeController', () => { auth: { message: 'CHOMP Authentication' }, chains: { [MOCK_CHAIN_ID]: { - autoDepositDelegate: MOCK_CONFIG.delegateAddress, + autoDepositDelegate: MOCK_DELEGATE_ADDRESS, protocol: { vedaProtocol: { supportedTokens: [], - adapterAddress: MOCK_CONFIG.vedaVaultAdapterAddress, + adapterAddress: MOCK_VEDA_VAULT_ADAPTER_ADDRESS, intentTypes: ['cash-deposit', 'cash-withdrawal'], }, }, @@ -252,7 +340,10 @@ describe('MoneyAccountUpgradeController', () => { }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow( `No supported tokens found for vedaProtocol on chain ${MOCK_CHAIN_ID}`, ); @@ -277,7 +368,10 @@ describe('MoneyAccountUpgradeController', () => { chains: {}, }); await expect( - controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG), + controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }), ).rejects.toThrow('Chain 0x1 not found in service details response'); await expect( @@ -287,9 +381,12 @@ describe('MoneyAccountUpgradeController', () => { ); }); - it('runs each step for the given address', async () => { + it('runs each step against the deployment-derived contract addresses', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); await controller.upgradeAccount(MOCK_ACCOUNT_ADDRESS); @@ -302,12 +399,12 @@ describe('MoneyAccountUpgradeController', () => { expect(mocks.signEip7702Authorization).toHaveBeenCalledWith( expect.objectContaining({ from: MOCK_ACCOUNT_ADDRESS, - contractAddress: MOCK_CONFIG.delegatorImplAddress, + contractAddress: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, }), ); expect(mocks.createUpgrade).toHaveBeenCalledWith( expect.objectContaining({ - address: MOCK_CONFIG.delegatorImplAddress, + address: MAINNET_CONTRACTS.EIP7702StatelessDeleGatorImpl, chainId: MOCK_CHAIN_ID, nonce: '0x0', }), @@ -316,7 +413,10 @@ describe('MoneyAccountUpgradeController', () => { it('is callable via the messenger', async () => { const { controller, rootMessenger } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); expect( await rootMessenger.call( @@ -328,7 +428,10 @@ describe('MoneyAccountUpgradeController', () => { it('propagates errors thrown by a step', async () => { const { controller, mocks } = setup(); - await controller.init(MOCK_CHAIN_ID, MOCK_INIT_CONFIG); + await controller.init({ + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT_ADDRESS, + }); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); await expect( diff --git a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts index 113fca4260..574a78f400 100644 --- a/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts +++ b/packages/money-account-upgrade-controller/src/MoneyAccountUpgradeController.ts @@ -1,3 +1,7 @@ +import type { + AuthenticatedUserStorageServiceCreateDelegationAction, + AuthenticatedUserStorageServiceListDelegationsAction, +} from '@metamask/authenticated-user-storage'; import type { ControllerGetStateAction, ControllerStateChangedEvent, @@ -6,9 +10,14 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { ChompApiServiceAssociateAddressAction, + ChompApiServiceCreateIntentsAction, ChompApiServiceCreateUpgradeAction, + ChompApiServiceGetIntentsByAddressAction, ChompApiServiceGetServiceDetailsAction, + ChompApiServiceVerifyDelegationAction, } from '@metamask/chomp-api-service'; +import type { DelegationControllerSignDelegationAction } from '@metamask/delegation-controller'; +import { DELEGATOR_CONTRACTS } from '@metamask/delegation-deployments'; import type { KeyringControllerSignEip7702AuthorizationAction, KeyringControllerSignPersonalMessageAction, @@ -18,13 +27,22 @@ import type { NetworkControllerFindNetworkClientIdByChainIdAction, NetworkControllerGetNetworkClientByIdAction, } from '@metamask/network-controller'; +import { hexToNumber } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import type { MoneyAccountUpgradeControllerMethodActions } from './MoneyAccountUpgradeController-method-action-types'; import { associateAddressStep } from './steps/associate-address'; +import { buildDelegationStep } from './steps/build-delegations'; import { eip7702AuthorizationStep } from './steps/eip-7702-authorization'; +import { registerIntentsStep } from './steps/register-intents'; import type { Step } from './steps/step'; -import type { InitConfig } from './types'; +import type { UpgradeConfig } from './types'; + +/** + * The Delegation Framework deployment version we resolve contract addresses + * against in `@metamask/delegation-deployments`. + */ +const DELEGATION_FRAMEWORK_VERSION = '1.3.0'; export const controllerName = 'MoneyAccountUpgradeController'; @@ -46,9 +64,15 @@ export type MoneyAccountUpgradeControllerActions = | MoneyAccountUpgradeControllerMethodActions; type AllowedActions = + | AuthenticatedUserStorageServiceCreateDelegationAction + | AuthenticatedUserStorageServiceListDelegationsAction | ChompApiServiceAssociateAddressAction + | ChompApiServiceCreateIntentsAction | ChompApiServiceCreateUpgradeAction + | ChompApiServiceGetIntentsByAddressAction | ChompApiServiceGetServiceDetailsAction + | ChompApiServiceVerifyDelegationAction + | DelegationControllerSignDelegationAction | KeyringControllerSignEip7702AuthorizationAction | KeyringControllerSignPersonalMessageAction | NetworkControllerFindNetworkClientIdByChainIdAction @@ -79,9 +103,14 @@ export class MoneyAccountUpgradeController extends BaseController< MoneyAccountUpgradeControllerState, MoneyAccountUpgradeControllerMessenger > { - #config?: { chainId: Hex; delegatorImplAddress: Hex }; + #config?: UpgradeConfig & { chainId: Hex }; - readonly #steps: Step[] = [associateAddressStep, eip7702AuthorizationStep]; + readonly #steps: Step[] = [ + associateAddressStep, + eip7702AuthorizationStep, + buildDelegationStep, + registerIntentsStep, + ]; /** * Constructor for the MoneyAccountUpgradeController. @@ -109,12 +138,31 @@ export class MoneyAccountUpgradeController extends BaseController< /** * Fetches service details and validates the controller can operate on the - * given chain. + * given chain. Resolves the Delegation Framework contract addresses for the + * chain from `@metamask/delegation-deployments`. * - * @param chainId - The chain to initialize for. - * @param initConfig - Contract addresses not available from the service details API. + * @param params - The parameters for initialization. + * @param params.chainId - The chain to initialize for. + * @param params.boringVaultAddress - The Veda boring vault contract + * (vmUSD) for the given chain. Used as the withdrawal-side delegation + * token. Supplied by the consumer until the CHOMP service-details API + * exposes it. */ - async init(chainId: Hex, initConfig: InitConfig): Promise { + async init({ + chainId, + boringVaultAddress, + }: { + chainId: Hex; + boringVaultAddress: Hex; + }): Promise { + const contracts = + DELEGATOR_CONTRACTS[DELEGATION_FRAMEWORK_VERSION][hexToNumber(chainId)]; + if (!contracts) { + throw new Error( + `Delegation Framework ${DELEGATION_FRAMEWORK_VERSION} is not deployed on chain ${chainId}`, + ); + } + const response = await this.messenger.call( 'ChompApiService:getServiceDetails', [chainId], @@ -140,7 +188,14 @@ export class MoneyAccountUpgradeController extends BaseController< this.#config = { chainId, - delegatorImplAddress: initConfig.delegatorImplAddress, + delegateAddress: chain.autoDepositDelegate, + musdTokenAddress: vedaProtocol.supportedTokens[0].tokenAddress, + boringVaultAddress, + vedaVaultAdapterAddress: vedaProtocol.adapterAddress, + delegatorImplAddress: contracts.EIP7702StatelessDeleGatorImpl, + erc20TransferAmountEnforcer: contracts.ERC20TransferAmountEnforcer, + redeemerEnforcer: contracts.RedeemerEnforcer, + valueLteEnforcer: contracts.ValueLteEnforcer, }; } diff --git a/packages/money-account-upgrade-controller/src/index.ts b/packages/money-account-upgrade-controller/src/index.ts index ffa40fc101..26d32b8711 100644 --- a/packages/money-account-upgrade-controller/src/index.ts +++ b/packages/money-account-upgrade-controller/src/index.ts @@ -1,4 +1,4 @@ -export type { InitConfig, UpgradeConfig } from './types'; +export type { UpgradeConfig } from './types'; export { MoneyAccountUpgradeController } from './MoneyAccountUpgradeController'; export type { MoneyAccountUpgradeControllerState, diff --git a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts index b141dc0ef4..da1b8491e3 100644 --- a/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/associate-address.test.ts @@ -11,7 +11,15 @@ import { associateAddressStep } from './associate-address'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0x1' as Hex; +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_SIGNATURE = '0xdeadbeefcafebabe'; const MOCK_NOW = new Date('2026-04-17T12:00:00.000Z').getTime(); @@ -64,6 +72,24 @@ function setup(): { return { messenger, mocks }; } +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return associateAddressStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + describe('associateAddressStep', () => { beforeEach(() => { jest.useFakeTimers(); @@ -81,12 +107,7 @@ describe('associateAddressStep', () => { it('signs the CHOMP Authentication message with the given address', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.signPersonalMessage).toHaveBeenCalledWith({ data: `CHOMP Authentication ${MOCK_NOW}`, @@ -97,12 +118,7 @@ describe('associateAddressStep', () => { it('submits the signature, timestamp, and address to the CHOMP API', async () => { const { messenger, mocks } = setup(); - await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + await run(messenger); expect(mocks.associateAddress).toHaveBeenCalledWith({ signature: MOCK_SIGNATURE, @@ -114,12 +130,7 @@ describe('associateAddressStep', () => { it('returns "completed" when CHOMP creates the association', async () => { const { messenger } = setup(); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('completed'); }); @@ -131,12 +142,7 @@ describe('associateAddressStep', () => { status: 'active', }); - const result = await associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }); + const result = await run(messenger); expect(result).toBe('already-done'); }); @@ -145,14 +151,7 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.signPersonalMessage.mockRejectedValue(new Error('signing failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('signing failed'); + await expect(run(messenger)).rejects.toThrow('signing failed'); expect(mocks.associateAddress).not.toHaveBeenCalled(); }); @@ -160,13 +159,6 @@ describe('associateAddressStep', () => { const { messenger, mocks } = setup(); mocks.associateAddress.mockRejectedValue(new Error('api failed')); - await expect( - associateAddressStep.run({ - messenger, - address: MOCK_ADDRESS, - chainId: MOCK_CHAIN_ID, - delegatorImplAddress: MOCK_DELEGATOR_IMPL, - }), - ).rejects.toThrow('api failed'); + await expect(run(messenger)).rejects.toThrow('api failed'); }); }); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts new file mode 100644 index 0000000000..dc383b4c1d --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.test.ts @@ -0,0 +1,579 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { buildDelegationStep } from './build-delegations'; + +jest.mock('@metamask/delegation-core', () => ({ + ROOT_AUTHORITY: + '0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff', + createERC20TransferAmountTerms: jest.fn(), + createRedeemerTerms: jest.fn(), + createValueLteTerms: jest.fn(), + hashDelegation: jest.fn(), +})); + +const mockCreateErc20Terms = jest.mocked(createERC20TransferAmountTerms); +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); +const mockCreateValueLteTerms = jest.mocked(createValueLteTerms); +const mockHashDelegation = jest.mocked(hashDelegation); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; +const MOCK_SIGNATURE: Hex = `0x${'cd'.repeat(65)}`; + +const MOCK_VALUE_LTE_TERMS: Hex = '0xa1'; +const MOCK_MUSD_ERC20_TERMS: Hex = '0xa2'; +const MOCK_VMUSD_ERC20_TERMS: Hex = '0xa4'; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; + +type ExpectedCaveat = { enforcer: Hex; terms: Hex; args: '0x' }; +const expectedCaveats = (erc20Terms: Hex): ExpectedCaveat[] => [ + { + enforcer: MOCK_VALUE_LTE_ENFORCER, + terms: MOCK_VALUE_LTE_TERMS, + args: '0x', + }, + { enforcer: MOCK_ERC20_ENFORCER, terms: erc20Terms, args: '0x' }, + { enforcer: MOCK_REDEEMER_ENFORCER, terms: MOCK_REDEEMER_TERMS, args: '0x' }, +]; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry, + * defaulting every identifying field to the deposit-side delegation, and + * including a redeemer caveat that points at the Veda vault adapter. Tests + * override one field at a time to probe the matcher. + * + * @param overrides - Identifying fields to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: ROOT_AUTHORITY as Hex, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: '0x' as Hex, + }, + metadata: { + delegationHash: `0x${'ab'.repeat(32)}`, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: '0x00', + tokenSymbol: 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: 'lend', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + signDelegation: jest.Mock; + verifyDelegation: jest.Mock; + createDelegation: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest.fn().mockResolvedValue([]), + signDelegation: jest.fn().mockResolvedValue(MOCK_SIGNATURE), + verifyDelegation: jest.fn().mockResolvedValue({ valid: true }), + createDelegation: jest.fn().mockResolvedValue(undefined), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:createDelegation', + mocks.createDelegation, + ); + rootMessenger.registerActionHandler( + 'DelegationController:signDelegation', + mocks.signDelegation, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:verifyDelegation', + mocks.verifyDelegation, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'AuthenticatedUserStorageService:createDelegation', + 'DelegationController:signDelegation', + 'ChompApiService:verifyDelegation', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return buildDelegationStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('buildDelegationStep', () => { + beforeEach(() => { + // The term creators are overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateValueLteTerms.mockReturnValue(MOCK_VALUE_LTE_TERMS as never); + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + // Return a different ERC20 terms blob per token so tests can tell which + // delegation was signed when. + mockCreateErc20Terms.mockImplementation((({ + tokenAddress, + }: { + tokenAddress: Hex; + }) => + tokenAddress === MOCK_MUSD + ? MOCK_MUSD_ERC20_TERMS + : MOCK_VMUSD_ERC20_TERMS) as never); + // Distinguish the two delegations by call order — the run loop signs + // mUSD first, then vmUSD, so the first hashDelegation call corresponds to + // mUSD. + mockHashDelegation + .mockReturnValueOnce(MOCK_MUSD_DELEGATION_HASH as never) + .mockReturnValueOnce(MOCK_VMUSD_DELEGATION_HASH as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "build-delegation"', () => { + expect(buildDelegationStep.name).toBe('build-delegation'); + }); + + describe('when neither delegation exists in storage', () => { + it('signs and submits both delegations, deposit (mUSD) before withdrawal (vmUSD)', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(2); + + const signedTokens = mocks.signDelegation.mock.calls.map( + ([{ delegation }]) => delegation.caveats[1].terms, + ); + expect(signedTokens).toStrictEqual([ + MOCK_MUSD_ERC20_TERMS, + MOCK_VMUSD_ERC20_TERMS, + ]); + }); + + it('encodes each caveat against the right enforcer addresses for each token', async () => { + const { messenger } = setup(); + + await run(messenger); + + // valueLte and redeemer share configuration across both delegations. + expect(mockCreateValueLteTerms).toHaveBeenCalledWith({ maxValue: 0n }); + expect(mockCreateRedeemerTerms).toHaveBeenCalledWith({ + redeemers: [MOCK_VAULT_ADAPTER], + }); + // erc20TransferAmount is per-token. + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_MUSD, + maxAmount: 2n ** 256n - 1n, + }); + expect(mockCreateErc20Terms).toHaveBeenCalledWith({ + tokenAddress: MOCK_BORING_VAULT, + maxAmount: 2n ** 256n - 1n, + }); + }); + + it('hands each unsigned delegation to DelegationController:signDelegation, scoped to the chain, with a fresh 32-byte salt', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.signDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, delegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(delegation.delegate).toBe(MOCK_DELEGATE); + expect(delegation.delegator).toBe(MOCK_ADDRESS); + expect(delegation.authority).toBe(ROOT_AUTHORITY); + expect(delegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + expect(delegation).not.toHaveProperty('signature'); + } + + expect(first.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.delegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + // Salts are independent per delegation. + expect(first.delegation.salt).not.toBe(second.delegation.salt); + }); + + it('submits each signed delegation to ChompApiService:verifyDelegation', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + const [first, second] = mocks.verifyDelegation.mock.calls.map( + ([params]) => params, + ); + + for (const { chainId, signedDelegation } of [first, second]) { + expect(chainId).toBe(MOCK_CHAIN_ID); + expect(signedDelegation.delegate).toBe(MOCK_DELEGATE); + expect(signedDelegation.delegator).toBe(MOCK_ADDRESS); + expect(signedDelegation.authority).toBe(ROOT_AUTHORITY); + expect(signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(signedDelegation.salt).toMatch(/^0x[0-9a-f]{64}$/u); + } + + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + }); + + it('persists each delegation via AuthenticatedUserStorageService:createDelegation, with deposit/withdrawal metadata', async () => { + const { messenger, mocks } = setup(); + + await run(messenger); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(2); + const [first, second] = mocks.createDelegation.mock.calls.map( + ([submission]) => submission, + ); + + // Each submission carries the same signed-delegation as the + // corresponding verifyDelegation call. + expect(first.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_MUSD_ERC20_TERMS), + ); + expect(first.signedDelegation.signature).toBe(MOCK_SIGNATURE); + expect(second.signedDelegation.caveats).toStrictEqual( + expectedCaveats(MOCK_VMUSD_ERC20_TERMS), + ); + expect(second.signedDelegation.signature).toBe(MOCK_SIGNATURE); + + expect(first.metadata).toStrictEqual({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }); + expect(second.metadata).toStrictEqual({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainIdHex: MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }); + }); + + it('hashes each signed delegation (with bigint salt) before persisting it', async () => { + const { messenger } = setup(); + + await run(messenger); + + expect(mockHashDelegation).toHaveBeenCalledTimes(2); + // Each hashDelegation call should receive a delegation whose salt is a + // bigint (delegation-core's expectation), not a hex string. + for (const [delegationStruct] of mockHashDelegation.mock.calls) { + expect(typeof delegationStruct.salt).toBe('bigint'); + expect(delegationStruct.signature).toBe(MOCK_SIGNATURE); + } + }); + }); + + describe('when only one delegation already exists', () => { + it('signs, submits, and persists only the missing withdrawal delegation when the deposit one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_VMUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_BORING_VAULT); + expect(submission.metadata.type).toBe('cash-withdrawal'); + }); + + it('signs, submits, and persists only the missing deposit delegation when the withdrawal one already exists', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + const { delegation } = mocks.signDelegation.mock.calls[0][0]; + expect(delegation.caveats[1].terms).toBe(MOCK_MUSD_ERC20_TERMS); + + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + const [submission] = mocks.createDelegation.mock.calls[0]; + expect(submission.metadata.tokenAddress).toBe(MOCK_MUSD); + expect(submission.metadata.type).toBe('cash-deposit'); + }); + }); + + describe('when both delegations already exist', () => { + it('returns "already-done" without signing, submitting, or persisting', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ tokenAddress: MOCK_MUSD }), + makeDelegationResponse({ tokenAddress: MOCK_BORING_VAULT }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('matches addresses, chainId, and tokenAddress case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD.toUpperCase() as Hex, + }), + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + }); + + it('ignores entries that differ on any identifying field', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // Same token but wrong delegator/delegate/chain. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegator: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegate: OTHER_ADDRESS, + }), + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + chainIdHex: OTHER_CHAIN_ID, + }), + // Unrelated token. + makeDelegationResponse({ tokenAddress: OTHER_TOKEN }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + + it('ignores entries that do not carry a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ tokenAddress: MOCK_MUSD, caveats: [] }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(2); + }); + }); + + describe('when CHOMP rejects a delegation', () => { + it('throws with the joined error list', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ + valid: false, + errors: ['caveat mismatch', 'unknown enforcer'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: caveat mismatch, unknown enforcer', + ); + }); + + it('throws with a default message when CHOMP returns no errors', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValue({ valid: false }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: unknown error', + ); + }); + + it('does not attempt the second delegation, and does not persist, if the first one is rejected', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockResolvedValueOnce({ + valid: false, + errors: ['nope'], + }); + + await expect(run(messenger)).rejects.toThrow( + 'CHOMP rejected delegation: nope', + ); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not sign or submit anything', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).not.toHaveBeenCalled(); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from signing and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.signDelegation.mockRejectedValue(new Error('signing failed')); + + await expect(run(messenger)).rejects.toThrow('signing failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from verifyDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.verifyDelegation.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).not.toHaveBeenCalled(); + }); + + it('propagates errors from createDelegation and stops the sequence', async () => { + const { messenger, mocks } = setup(); + mocks.createDelegation.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.signDelegation).toHaveBeenCalledTimes(1); + expect(mocks.verifyDelegation).toHaveBeenCalledTimes(1); + expect(mocks.createDelegation).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/build-delegations.ts b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts new file mode 100644 index 0000000000..3edf32fe29 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/build-delegations.ts @@ -0,0 +1,204 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { + ROOT_AUTHORITY, + createERC20TransferAmountTerms, + createRedeemerTerms, + createValueLteTerms, + hashDelegation, +} from '@metamask/delegation-core'; +import { add0x, bytesToHex } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers'; +import type { Step } from './step'; + +const MAX_UINT256 = 2n ** 256n - 1n; +const MAX_UINT256_HEX: Hex = add0x(MAX_UINT256.toString(16)); + +/** + * Builds, signs, verifies (with CHOMP), and persists a single auto-deposit + * delegation for the given token. Both the deposit (mUSD) and withdrawal + * (vmUSD / boring vault) delegations share this shape; only the token + * address, symbol, and metadata `type` differ. + * + * @param params - The parameters for building the delegation. + * @param params.messenger - The messenger to call signing/verifying actions on. + * @param params.address - The delegator (the Money Account being upgraded). + * @param params.chainId - The chain to scope the delegation to. + * @param params.delegateAddress - CHOMP's delegate. + * @param params.tokenAddress - The token the delegation authorises transfers of. + * @param params.tokenSymbol - Symbol stored in the delegation metadata (e.g. "mUSD"). + * @param params.delegationType - Storage metadata `type` field; matches CHOMP's intent type. + * @param params.vedaVaultAdapterAddress - The redeemer (Veda vault adapter). + * @param params.erc20TransferAmountEnforcer - The ERC20TransferAmountEnforcer contract. + * @param params.redeemerEnforcer - The RedeemerEnforcer contract. + * @param params.valueLteEnforcer - The ValueLteEnforcer contract. + */ +async function signAndStoreDelegation(params: { + messenger: MoneyAccountUpgradeControllerMessenger; + address: Hex; + chainId: Hex; + delegateAddress: Hex; + tokenAddress: Hex; + tokenSymbol: string; + delegationType: 'cash-deposit' | 'cash-withdrawal'; + vedaVaultAdapterAddress: Hex; + erc20TransferAmountEnforcer: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; +}): Promise { + const { + messenger, + address, + chainId, + delegateAddress, + tokenAddress, + tokenSymbol, + delegationType, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + } = params; + + const saltBytes = globalThis.crypto.getRandomValues(new Uint8Array(32)); + const salt = bytesToHex(saltBytes); + + const delegation = { + delegate: delegateAddress, + delegator: address, + authority: ROOT_AUTHORITY, + caveats: [ + { + enforcer: valueLteEnforcer, + terms: createValueLteTerms({ maxValue: 0n }), + args: '0x' as Hex, + }, + { + enforcer: erc20TransferAmountEnforcer, + terms: createERC20TransferAmountTerms({ + tokenAddress, + maxAmount: MAX_UINT256, + }), + args: '0x' as Hex, + }, + { + enforcer: redeemerEnforcer, + terms: createRedeemerTerms({ redeemers: [vedaVaultAdapterAddress] }), + args: '0x' as Hex, + }, + ], + salt, + }; + + const signature = (await messenger.call( + 'DelegationController:signDelegation', + { delegation, chainId }, + )) as Hex; + + const signedDelegation = { ...delegation, signature }; + + const result = await messenger.call('ChompApiService:verifyDelegation', { + signedDelegation, + chainId, + }); + + if (!result.valid) { + throw new Error( + `CHOMP rejected delegation: ${result.errors?.join(', ') ?? 'unknown error'}`, + ); + } + + const delegationHash = hashDelegation({ + ...delegation, + salt: BigInt(salt), + signature, + }); + + await messenger.call('AuthenticatedUserStorageService:createDelegation', { + signedDelegation, + metadata: { + delegationHash, + chainIdHex: chainId, + allowance: MAX_UINT256_HEX, + tokenSymbol, + tokenAddress, + type: delegationType, + }, + }); +} + +export const buildDelegationStep: Step = { + name: 'build-delegation', + async run({ + messenger, + address, + chainId, + boringVaultAddress, + delegateAddress, + erc20TransferAmountEnforcer, + musdTokenAddress, + redeemerEnforcer, + valueLteEnforcer, + vedaVaultAdapterAddress, + }) { + const existingDelegations = await messenger.call( + 'AuthenticatedUserStorageService:listDelegations', + ); + + const hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + const matches = + (tokenAddress: Hex) => + (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + equalsIgnoreCase(entry.metadata.tokenAddress, tokenAddress) && + hasVedaRedeemerCaveat(entry); + + // The deposit delegation authorises transfers of mUSD (delegator → vault); + // the withdrawal delegation authorises transfers of vmUSD (vault share + // token → adapter, which redeems back to mUSD). + const delegations = [ + { + tokenAddress: musdTokenAddress, + tokenSymbol: 'mUSD', + delegationType: 'cash-deposit' as const, + }, + { + tokenAddress: boringVaultAddress, + tokenSymbol: 'vmUSD', + delegationType: 'cash-withdrawal' as const, + }, + ]; + + let didWork = false; + for (const config of delegations) { + if (existingDelegations.some(matches(config.tokenAddress))) { + continue; + } + await signAndStoreDelegation({ + messenger, + address, + chainId, + delegateAddress, + ...config, + vedaVaultAdapterAddress, + erc20TransferAmountEnforcer, + redeemerEnforcer, + valueLteEnforcer, + }); + didWork = true; + } + + return didWork ? 'completed' : 'already-done'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts new file mode 100644 index 0000000000..0c78973d52 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/delegation-matchers.ts @@ -0,0 +1,32 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import type { Hex } from '@metamask/utils'; + +export const equalsIgnoreCase = (a: Hex, b: Hex): boolean => + a.toLowerCase() === b.toLowerCase(); + +/** + * Builds a predicate that matches stored delegations carrying a redeemer + * caveat targeting the Veda vault adapter — i.e. delegations we wrote for + * auto-deposit / auto-withdrawal. The expected terms blob is computed once + * and reused across calls. + * + * @param redeemerEnforcer - The RedeemerEnforcer contract address. + * @param vedaVaultAdapterAddress - The Veda vault adapter address that must + * be encoded as the sole redeemer. + * @returns A predicate over `DelegationResponse`. + */ +export const makeHasVedaRedeemerCaveat = ( + redeemerEnforcer: Hex, + vedaVaultAdapterAddress: Hex, +): ((entry: DelegationResponse) => boolean) => { + const expectedRedeemerTerms = createRedeemerTerms({ + redeemers: [vedaVaultAdapterAddress], + }); + return (entry) => + entry.signedDelegation.caveats.some( + (caveat) => + equalsIgnoreCase(caveat.enforcer, redeemerEnforcer) && + equalsIgnoreCase(caveat.terms, expectedRedeemerTerms), + ); +}; diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts index d11bf4548c..e1c179fc8f 100644 --- a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.test.ts @@ -12,7 +12,15 @@ import { eip7702AuthorizationStep } from './eip-7702-authorization'; const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) — non-trivial decimal const MOCK_CHAIN_ID_DECIMAL = parseInt(MOCK_CHAIN_ID, 16); +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; const MOCK_DELEGATOR_IMPL = '0x2222222222222222222222222222222222222222' as Hex; +const MOCK_TOKEN = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0x5555555555555555555555555555555555555555' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0x6666666666666666666666666666666666666666' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0x7777777777777777777777777777777777777777' as Hex; const MOCK_THIRD_PARTY_IMPL = '0x9999999999999999999999999999999999999999' as Hex; const MOCK_NETWORK_CLIENT_ID = 'network-client-id'; @@ -142,7 +150,14 @@ async function run( messenger, address: MOCK_ADDRESS, chainId: MOCK_CHAIN_ID, + boringVaultAddress: '0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as Hex, + delegateAddress: MOCK_DELEGATE, delegatorImplAddress: MOCK_DELEGATOR_IMPL, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_TOKEN, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, }); } @@ -312,6 +327,37 @@ describe('eip7702AuthorizationStep', () => { await expect(run(messenger)).rejects.toThrow('api failed'); }); + it('returns "already-done" when CHOMP responds 409 (authorization already submitted)', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('conflict'), { httpStatus: 409 }), + ); + + expect(await run(messenger)).toBe('already-done'); + }); + + it('propagates non-409 HttpError responses from createUpgrade', async () => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue( + Object.assign(new Error('server error'), { httpStatus: 500 }), + ); + + await expect(run(messenger)).rejects.toThrow('server error'); + }); + + it.each([ + ['a string', 'boom'], + ['null', null], + ])( + 'propagates non-object rejections from createUpgrade (%s)', + async (_label, rejection) => { + const { messenger, mocks } = setup(); + mocks.createUpgrade.mockRejectedValue(rejection); + + await expect(run(messenger)).rejects.toBe(rejection); + }, + ); + it('throws when eth_getTransactionCount returns a non-hex response', async () => { const { messenger, mocks } = setup(); mocks.providerRequest.mockImplementation(async ({ method }) => { diff --git a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts index 11e73d5877..f494a67945 100644 --- a/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts +++ b/packages/money-account-upgrade-controller/src/steps/eip-7702-authorization.ts @@ -67,20 +67,40 @@ export const eip7702AuthorizationStep: Step = { const { r, s, v, yParity } = splitEip7702Signature(signature); - await messenger.call('ChompApiService:createUpgrade', { - r, - s, - v, - yParity, - address: delegatorImplAddress, - chainId, - nonce: add0x(nonce.toString(16)), - }); + try { + await messenger.call('ChompApiService:createUpgrade', { + r, + s, + v, + yParity, + address: delegatorImplAddress, + chainId, + nonce: add0x(nonce.toString(16)), + }); + } catch (error) { + // CHOMP returns 409 when an authorization for this address already + // exists with the same or higher nonce — typically on retry when a + // previous submission was accepted but hasn't yet been observed + // on-chain (so `fetchDelegationAddress` returned undefined above). + // Treat as already-done so the upgrade sequence is retry-safe. + if (isHttp409(error)) { + return 'already-done'; + } + throw error; + } return 'completed'; }, }; +function isHttp409(error: unknown): boolean { + if (typeof error !== 'object' || error === null) { + return false; + } + const { httpStatus } = error as { httpStatus?: unknown }; + return httpStatus === 409; +} + /** * Splits a 65-byte ECDSA signature produced by * `KeyringController:signEip7702Authorization` into its `r`, `s`, `v` diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts new file mode 100644 index 0000000000..6a807cfe75 --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.test.ts @@ -0,0 +1,509 @@ +import type { + DelegationResponse, + DelegationMetadata, +} from '@metamask/authenticated-user-storage'; +import type { IntentEntry } from '@metamask/chomp-api-service'; +import { createRedeemerTerms } from '@metamask/delegation-core'; +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import type { Hex } from '@metamask/utils'; + +import type { MoneyAccountUpgradeControllerMessenger } from '../MoneyAccountUpgradeController'; +import { registerIntentsStep } from './register-intents'; + +jest.mock('@metamask/delegation-core', () => ({ + createRedeemerTerms: jest.fn(), +})); + +const mockCreateRedeemerTerms = jest.mocked(createRedeemerTerms); + +const MOCK_ADDRESS = '0xabcdef1234567890abcdef1234567890abcdef12' as Hex; +const MOCK_CHAIN_ID = '0xaa36a7' as Hex; // 11155111 (Sepolia) +const MOCK_DELEGATE = '0x1111111111111111111111111111111111111111' as Hex; +const MOCK_MUSD = '0x3333333333333333333333333333333333333333' as Hex; +const MOCK_BORING_VAULT = '0x7777777777777777777777777777777777777777' as Hex; +const MOCK_VAULT_ADAPTER = '0x4444444444444444444444444444444444444444' as Hex; +const MOCK_ERC20_ENFORCER = '0xaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' as Hex; +const MOCK_REDEEMER_ENFORCER = + '0xbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' as Hex; +const MOCK_VALUE_LTE_ENFORCER = + '0xcccccccccccccccccccccccccccccccccccccccc' as Hex; +const OTHER_ADDRESS = '0x9999999999999999999999999999999999999999' as Hex; +const OTHER_CHAIN_ID = '0x1' as Hex; +const OTHER_TOKEN = '0x8888888888888888888888888888888888888888' as Hex; + +const MOCK_MUSD_DELEGATION_HASH: Hex = `0x${'ee'.repeat(32)}`; +const MOCK_VMUSD_DELEGATION_HASH: Hex = `0x${'ff'.repeat(32)}`; +const MAX_UINT256_HEX: Hex = `0x${'f'.repeat(64)}`; +const MOCK_REDEEMER_TERMS: Hex = '0xa3'; + +/** + * Builds a `DelegationResponse` for use as a mocked `listDelegations` entry. + * Defaults match the deposit-side delegation written by the build-delegation + * step, including a redeemer caveat that points at the Veda vault adapter. + * Tests override identifying fields and metadata to probe the matcher. + * + * @param overrides - Identifying fields and metadata to override. + * @param overrides.delegator - The delegator address. + * @param overrides.delegate - The delegate address. + * @param overrides.chainIdHex - The chain ID in hex. + * @param overrides.tokenAddress - The token address. + * @param overrides.tokenSymbol - The token symbol. + * @param overrides.delegationHash - The delegation hash recorded in metadata. + * @param overrides.type - The metadata `type` field. + * @param overrides.caveats - The caveats attached to the delegation. Defaults + * to a single redeemer caveat targeting the Veda vault adapter. + * @returns A complete `DelegationResponse`. + */ +function makeDelegationResponse( + overrides: { + delegator?: Hex; + delegate?: Hex; + chainIdHex?: Hex; + tokenAddress?: Hex; + tokenSymbol?: string; + delegationHash?: Hex; + type?: DelegationMetadata['type']; + caveats?: { enforcer: Hex; terms: Hex; args: Hex }[]; + } = {}, +): DelegationResponse { + return { + signedDelegation: { + delegate: overrides.delegate ?? MOCK_DELEGATE, + delegator: overrides.delegator ?? MOCK_ADDRESS, + authority: `0x${'ff'.repeat(32)}`, + caveats: overrides.caveats ?? [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: MOCK_REDEEMER_TERMS, + args: '0x', + }, + ], + salt: `0x${'42'.repeat(32)}`, + signature: `0x${'cd'.repeat(65)}`, + }, + metadata: { + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainIdHex: overrides.chainIdHex ?? MOCK_CHAIN_ID, + allowance: MAX_UINT256_HEX, + tokenSymbol: overrides.tokenSymbol ?? 'mUSD', + tokenAddress: overrides.tokenAddress ?? MOCK_MUSD, + type: overrides.type ?? 'cash-deposit', + }, + }; +} + +const depositDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }); + +const withdrawalDelegation = (): DelegationResponse => + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + }); + +/** + * Builds an `IntentEntry` for use as a mocked `getIntentsByAddress` entry. + * Defaults to an active deposit-side intent matching the deposit delegation. + * + * @param overrides - Fields to override. + * @param overrides.delegationHash - The delegationHash this intent points at. + * @param overrides.status - The intent status (active or revoked). + * @returns A complete `IntentEntry`. + */ +function makeIntentEntry( + overrides: { delegationHash?: Hex; status?: IntentEntry['status'] } = {}, +): IntentEntry { + return { + account: MOCK_ADDRESS, + delegationHash: overrides.delegationHash ?? MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + status: overrides.status ?? 'active', + metadata: { + allowance: MAX_UINT256_HEX, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + type: 'cash-deposit', + }, + }; +} + +type AllActions = MessengerActions; +type AllEvents = MessengerEvents; + +type Mocks = { + listDelegations: jest.Mock; + getIntentsByAddress: jest.Mock; + createIntents: jest.Mock; +}; + +function setup(): { + messenger: MoneyAccountUpgradeControllerMessenger; + mocks: Mocks; +} { + const mocks: Mocks = { + listDelegations: jest + .fn() + .mockResolvedValue([depositDelegation(), withdrawalDelegation()]), + getIntentsByAddress: jest.fn().mockResolvedValue([]), + createIntents: jest.fn().mockResolvedValue([]), + }; + + const rootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + rootMessenger.registerActionHandler( + 'AuthenticatedUserStorageService:listDelegations', + mocks.listDelegations, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:getIntentsByAddress', + mocks.getIntentsByAddress, + ); + rootMessenger.registerActionHandler( + 'ChompApiService:createIntents', + mocks.createIntents, + ); + + const messenger: MoneyAccountUpgradeControllerMessenger = new Messenger({ + namespace: 'MoneyAccountUpgradeController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticatedUserStorageService:listDelegations', + 'ChompApiService:getIntentsByAddress', + 'ChompApiService:createIntents', + ], + events: [], + messenger, + }); + + return { messenger, mocks }; +} + +async function run( + messenger: MoneyAccountUpgradeControllerMessenger, +): ReturnType { + return registerIntentsStep.run({ + messenger, + address: MOCK_ADDRESS, + chainId: MOCK_CHAIN_ID, + boringVaultAddress: MOCK_BORING_VAULT, + delegateAddress: MOCK_DELEGATE, + delegatorImplAddress: '0x2222222222222222222222222222222222222222' as Hex, + erc20TransferAmountEnforcer: MOCK_ERC20_ENFORCER, + musdTokenAddress: MOCK_MUSD, + redeemerEnforcer: MOCK_REDEEMER_ENFORCER, + valueLteEnforcer: MOCK_VALUE_LTE_ENFORCER, + vedaVaultAdapterAddress: MOCK_VAULT_ADAPTER, + }); +} + +describe('registerIntentsStep', () => { + beforeEach(() => { + // The terms factory is overloaded over output encoding; the runtime path + // picks the hex overload, but `jest.mocked()` picks the bytes overload, so + // cast through `never` to satisfy both. + mockCreateRedeemerTerms.mockReturnValue(MOCK_REDEEMER_TERMS as never); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('is named "register-intents"', () => { + expect(registerIntentsStep.name).toBe('register-intents'); + }); + + describe('when no intents exist for the account', () => { + it('submits an intent for each stored delegation and returns "completed"', async () => { + const { messenger, mocks } = setup(); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toStrictEqual([ + { + account: MOCK_ADDRESS, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'mUSD', + tokenAddress: MOCK_MUSD, + type: 'cash-deposit', + }, + }, + { + account: MOCK_ADDRESS, + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + chainId: MOCK_CHAIN_ID, + metadata: { + allowance: MAX_UINT256_HEX, + tokenSymbol: 'vmUSD', + tokenAddress: MOCK_BORING_VAULT, + type: 'cash-withdrawal', + }, + }, + ]); + }); + }); + + describe('when an active intent already exists for one delegation', () => { + it('submits only the missing intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_VMUSD_DELEGATION_HASH); + expect(submitted[0].metadata.type).toBe('cash-withdrawal'); + }); + + it('matches delegationHash case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH.toUpperCase() as Hex, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when active intents already exist for both delegations', () => { + it('returns "already-done" without calling createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ delegationHash: MOCK_MUSD_DELEGATION_HASH }), + makeIntentEntry({ delegationHash: MOCK_VMUSD_DELEGATION_HASH }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when an intent exists but is revoked', () => { + it('re-registers the revoked intent', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockResolvedValue([ + makeIntentEntry({ + delegationHash: MOCK_MUSD_DELEGATION_HASH, + status: 'revoked', + }), + makeIntentEntry({ + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + status: 'active', + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + expect(mocks.createIntents).toHaveBeenCalledTimes(1); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(1); + expect(submitted[0].delegationHash).toBe(MOCK_MUSD_DELEGATION_HASH); + }); + }); + + describe('filtering stored delegations', () => { + it('ignores delegations from a different delegator', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + delegationHash: `0x${'01'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + expect( + submitted.map( + (intent: { delegationHash: Hex }) => intent.delegationHash, + ), + ).toStrictEqual([MOCK_MUSD_DELEGATION_HASH, MOCK_VMUSD_DELEGATION_HASH]); + }); + + it('ignores delegations to a different delegate', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegate: OTHER_ADDRESS, + delegationHash: `0x${'02'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('ignores delegations on a different chain', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + chainIdHex: OTHER_CHAIN_ID, + delegationHash: `0x${'03'.repeat(32)}`, + }), + depositDelegation(), + withdrawalDelegation(), + ]); + + await run(messenger); + + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('matches identifying fields case-insensitively', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: MOCK_ADDRESS.toUpperCase() as Hex, + delegate: MOCK_DELEGATE.toUpperCase() as Hex, + chainIdHex: MOCK_CHAIN_ID.toUpperCase() as Hex, + tokenAddress: MOCK_MUSD, + tokenSymbol: 'mUSD', + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'cash-deposit', + }), + withdrawalDelegation(), + ]); + + const result = await run(messenger); + + expect(result).toBe('completed'); + const [submitted] = mocks.createIntents.mock.calls[0]; + expect(submitted).toHaveLength(2); + }); + + it('returns "already-done" when no delegations match the filter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + delegator: OTHER_ADDRESS, + tokenAddress: OTHER_TOKEN, + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('ignores delegations whose caveats do not include a redeemer caveat targeting the Veda vault adapter', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + // No caveats at all. + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + caveats: [], + }), + // Right enforcer, wrong terms (different redeemer encoded). + makeDelegationResponse({ + tokenAddress: MOCK_BORING_VAULT, + tokenSymbol: 'vmUSD', + delegationHash: MOCK_VMUSD_DELEGATION_HASH, + type: 'cash-withdrawal', + caveats: [ + { + enforcer: MOCK_REDEEMER_ENFORCER, + terms: '0xdeadbeef', + args: '0x', + }, + ], + }), + ]); + + const result = await run(messenger); + + expect(result).toBe('already-done'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('when a stored delegation has an unrecognized metadata type', () => { + it('throws rather than coercing into a CHOMP intent', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockResolvedValue([ + makeDelegationResponse({ + tokenAddress: MOCK_MUSD, + delegationHash: MOCK_MUSD_DELEGATION_HASH, + type: 'lend', + }), + ]); + + await expect(run(messenger)).rejects.toThrow( + 'Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "lend"', + ); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + }); + + describe('error propagation', () => { + it('propagates errors from listDelegations and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.listDelegations.mockRejectedValue(new Error('storage failed')); + + await expect(run(messenger)).rejects.toThrow('storage failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from getIntentsByAddress and does not call createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.getIntentsByAddress.mockRejectedValue(new Error('chomp failed')); + + await expect(run(messenger)).rejects.toThrow('chomp failed'); + expect(mocks.createIntents).not.toHaveBeenCalled(); + }); + + it('propagates errors from createIntents', async () => { + const { messenger, mocks } = setup(); + mocks.createIntents.mockRejectedValue(new Error('submit failed')); + + await expect(run(messenger)).rejects.toThrow('submit failed'); + }); + }); +}); diff --git a/packages/money-account-upgrade-controller/src/steps/register-intents.ts b/packages/money-account-upgrade-controller/src/steps/register-intents.ts new file mode 100644 index 0000000000..8dd3d769ec --- /dev/null +++ b/packages/money-account-upgrade-controller/src/steps/register-intents.ts @@ -0,0 +1,101 @@ +import type { DelegationResponse } from '@metamask/authenticated-user-storage'; +import type { + IntentEntry, + SendIntentParams, +} from '@metamask/chomp-api-service'; + +import { + equalsIgnoreCase, + makeHasVedaRedeemerCaveat, +} from './delegation-matchers'; +import type { Step } from './step'; + +type IntentMetadataType = SendIntentParams['metadata']['type']; + +/** + * Parses a delegation's metadata `type` field — typed as `string` in storage — + * into the narrow set of CHOMP intent types. Throws if the field carries any + * other value, since registering it as an intent would be a category error. + * + * @param type - The `type` field from `DelegationMetadata`. + * @returns The same value, narrowed to `IntentMetadataType`. + */ +function parseIntentMetadataType(type: string): IntentMetadataType { + if (type !== 'cash-deposit' && type !== 'cash-withdrawal') { + throw new Error( + `Expected delegation type to be "cash-deposit" or "cash-withdrawal", got "${type}"`, + ); + } + return type; +} + +/** + * Registers CHOMP intents for the auto-deposit / auto-withdrawal delegations + * persisted by the build-delegation step. + * + * For each stored delegation between this account and CHOMP's delegate on + * this chain, the step builds an intent referencing the stored + * `delegationHash` and submits the batch to `POST /v1/intent`. Delegations + * whose `delegationHash` already has an active intent on CHOMP are skipped + * (revoked intents are re-registered). Reports `'already-done'` when every + * eligible delegation already has an active intent. + * + * Once registered, CHOMP re-fetches the delegation from Authenticated User + * Storage, re-validates it, and adds the account to its monitoring list so + * subsequent eligible operations can be picked up automatically. + */ +export const registerIntentsStep: Step = { + name: 'register-intents', + async run({ + messenger, + address, + chainId, + delegateAddress, + redeemerEnforcer, + vedaVaultAdapterAddress, + }) { + const [delegations, existingIntents] = await Promise.all([ + messenger.call('AuthenticatedUserStorageService:listDelegations'), + messenger.call('ChompApiService:getIntentsByAddress', address), + ]); + + const activeIntentHashes = new Set( + existingIntents + .filter((intent: IntentEntry) => intent.status === 'active') + .map((intent: IntentEntry) => intent.delegationHash.toLowerCase()), + ); + + const hasVedaRedeemerCaveat = makeHasVedaRedeemerCaveat( + redeemerEnforcer, + vedaVaultAdapterAddress, + ); + + const needsIntent = (entry: DelegationResponse): boolean => + equalsIgnoreCase(entry.signedDelegation.delegator, address) && + equalsIgnoreCase(entry.signedDelegation.delegate, delegateAddress) && + equalsIgnoreCase(entry.metadata.chainIdHex, chainId) && + hasVedaRedeemerCaveat(entry) && + !activeIntentHashes.has(entry.metadata.delegationHash.toLowerCase()); + + const toIntent = (entry: DelegationResponse): SendIntentParams => ({ + account: address, + delegationHash: entry.metadata.delegationHash, + chainId, + metadata: { + allowance: entry.metadata.allowance, + tokenSymbol: entry.metadata.tokenSymbol, + tokenAddress: entry.metadata.tokenAddress, + type: parseIntentMetadataType(entry.metadata.type), + }, + }); + + const intents = delegations.filter(needsIntent).map(toIntent); + + if (intents.length === 0) { + return 'already-done'; + } + + await messenger.call('ChompApiService:createIntents', intents); + return 'completed'; + }, +}; diff --git a/packages/money-account-upgrade-controller/src/steps/step.ts b/packages/money-account-upgrade-controller/src/steps/step.ts index fa164d3354..9537119d8a 100644 --- a/packages/money-account-upgrade-controller/src/steps/step.ts +++ b/packages/money-account-upgrade-controller/src/steps/step.ts @@ -9,7 +9,14 @@ export type StepContext = { messenger: MoneyAccountUpgradeControllerMessenger; address: Hex; chainId: Hex; + boringVaultAddress: Hex; + delegateAddress: Hex; delegatorImplAddress: Hex; + erc20TransferAmountEnforcer: Hex; + musdTokenAddress: Hex; + redeemerEnforcer: Hex; + valueLteEnforcer: Hex; + vedaVaultAdapterAddress: Hex; }; /** diff --git a/packages/money-account-upgrade-controller/src/types.ts b/packages/money-account-upgrade-controller/src/types.ts index c6a18dc179..db8db0ab26 100644 --- a/packages/money-account-upgrade-controller/src/types.ts +++ b/packages/money-account-upgrade-controller/src/types.ts @@ -1,18 +1,25 @@ import type { Hex } from '@metamask/utils'; /** - * Contract addresses and configuration required to perform the - * Money Account upgrade sequence. + * Configuration required to perform the Money Account upgrade sequence. + * + * `delegateAddress`, `musdTokenAddress`, and `vedaVaultAdapterAddress` come + * from the CHOMP service details API. `delegatorImplAddress` and the caveat + * enforcer addresses are resolved from `@metamask/delegation-deployments` for + * the target chain. (DelegationManager resolution is delegated to + * `@metamask/delegation-controller`, which handles delegation signing.) */ export type UpgradeConfig = { /** CHOMP's delegate address — receives the delegation. */ delegateAddress: Hex; - /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ - delegatorImplAddress: Hex; - /** The mUSD token contract address. */ + /** The mUSD token contract address (deposit-side delegation token). */ musdTokenAddress: Hex; + /** The Veda boring vault contract address (withdrawal-side delegation token, vmUSD). */ + boringVaultAddress: Hex; /** The Veda vault adapter contract address. */ vedaVaultAdapterAddress: Hex; + /** The EIP-7702 delegation target (EIP7702StatelessDeleGatorImpl). */ + delegatorImplAddress: Hex; /** Address of the ERC20TransferAmountEnforcer caveat enforcer. */ erc20TransferAmountEnforcer: Hex; /** Address of the RedeemerEnforcer caveat enforcer. */ @@ -20,15 +27,3 @@ export type UpgradeConfig = { /** Address of the ValueLteEnforcer caveat enforcer. */ valueLteEnforcer: Hex; }; - -/** - * Configuration values passed to {@link MoneyAccountUpgradeController.init} - * that cannot be derived from the service details API. - */ -export type InitConfig = Pick< - UpgradeConfig, - | 'delegatorImplAddress' - | 'musdTokenAddress' - | 'redeemerEnforcer' - | 'valueLteEnforcer' ->; diff --git a/packages/money-account-upgrade-controller/tsconfig.build.json b/packages/money-account-upgrade-controller/tsconfig.build.json index b69bb81cca..033cb7d8b0 100644 --- a/packages/money-account-upgrade-controller/tsconfig.build.json +++ b/packages/money-account-upgrade-controller/tsconfig.build.json @@ -6,8 +6,10 @@ "rootDir": "./src" }, "references": [ + { "path": "../authenticated-user-storage/tsconfig.build.json" }, { "path": "../base-controller/tsconfig.build.json" }, { "path": "../chomp-api-service/tsconfig.build.json" }, + { "path": "../delegation-controller/tsconfig.build.json" }, { "path": "../keyring-controller/tsconfig.build.json" }, { "path": "../messenger/tsconfig.build.json" }, { "path": "../network-controller/tsconfig.build.json" }, diff --git a/packages/money-account-upgrade-controller/tsconfig.json b/packages/money-account-upgrade-controller/tsconfig.json index ffcde5ec67..7993854f44 100644 --- a/packages/money-account-upgrade-controller/tsconfig.json +++ b/packages/money-account-upgrade-controller/tsconfig.json @@ -4,8 +4,10 @@ "baseUrl": "./" }, "references": [ + { "path": "../authenticated-user-storage" }, { "path": "../base-controller" }, { "path": "../chomp-api-service" }, + { "path": "../delegation-controller" }, { "path": "../keyring-controller" }, { "path": "../messenger" }, { "path": "../network-controller" }, diff --git a/yarn.lock b/yarn.lock index 4c2c3023aa..425954d381 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2926,7 +2926,7 @@ __metadata: languageName: node linkType: hard -"@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": +"@metamask/authenticated-user-storage@npm:^1.0.1, @metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage": version: 0.0.0-use.local resolution: "@metamask/authenticated-user-storage@workspace:packages/authenticated-user-storage" dependencies: @@ -3480,7 +3480,7 @@ __metadata: languageName: node linkType: hard -"@metamask/delegation-controller@workspace:packages/delegation-controller": +"@metamask/delegation-controller@npm:^3.0.0, @metamask/delegation-controller@workspace:packages/delegation-controller": version: 0.0.0-use.local resolution: "@metamask/delegation-controller@workspace:packages/delegation-controller" dependencies: @@ -4540,9 +4540,13 @@ __metadata: version: 0.0.0-use.local resolution: "@metamask/money-account-upgrade-controller@workspace:packages/money-account-upgrade-controller" dependencies: + "@metamask/authenticated-user-storage": "npm:^1.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/chomp-api-service": "npm:^3.0.1" + "@metamask/delegation-controller": "npm:^3.0.0" + "@metamask/delegation-core": "npm:^2.0.0" + "@metamask/delegation-deployments": "npm:^1.3.0" "@metamask/keyring-controller": "npm:^25.5.0" "@metamask/messenger": "npm:^1.2.0" "@metamask/network-controller": "npm:^31.1.0" @@ -4551,6 +4555,7 @@ __metadata: "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" 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" @@ -7444,7 +7449,7 @@ __metadata: languageName: node linkType: hard -"abitype@npm:1.2.3, abitype@npm:^1.2.3": +"abitype@npm:1.2.3": version: 1.2.3 resolution: "abitype@npm:1.2.3" peerDependencies: @@ -7459,6 +7464,21 @@ __metadata: languageName: node linkType: hard +"abitype@npm:^1.2.3": + version: 1.2.4 + resolution: "abitype@npm:1.2.4" + peerDependencies: + typescript: ">=5.0.4" + zod: ^3.22.0 || ^4.0.0 + peerDependenciesMeta: + typescript: + optional: true + zod: + optional: true + checksum: 10/500b317a53b34cb6ffe3e4f090e135972b43cd2fbdfebe64fc497dfd8515d9117919e5f88f0aaede332d29a21c1826be64a3ffa620b0b91c16e8b560b6635714 + languageName: node + linkType: hard + "abort-controller@npm:^3.0.0": version: 3.0.0 resolution: "abort-controller@npm:3.0.0" @@ -12880,9 +12900,9 @@ __metadata: languageName: node linkType: hard -"ox@npm:0.12.4": - version: 0.12.4 - resolution: "ox@npm:0.12.4" +"ox@npm:0.14.20": + version: 0.14.20 + resolution: "ox@npm:0.14.20" dependencies: "@adraffy/ens-normalize": "npm:^1.11.0" "@noble/ciphers": "npm:^1.3.0" @@ -12897,7 +12917,7 @@ __metadata: peerDependenciesMeta: typescript: optional: true - checksum: 10/077509b841658693a411df505d0bdbbee2d68734aa19736ccff5a6087c119c4aebc1d8d8c2039ca9f16ae7430cb44812e4c182f858cab67c9a755dd0e9914178 + checksum: 10/96526073193f3a6dd2ccd21bcc255e82c7226d6de63fa17a2021c75232fdc9bc969e75e2cbc0c8d5163d88c575e08dc4c75dec7333b1727f080585f07fc6c1ed languageName: node linkType: hard @@ -15048,8 +15068,8 @@ __metadata: linkType: hard "viem@npm:^2.36.0": - version: 2.46.2 - resolution: "viem@npm:2.46.2" + version: 2.48.4 + resolution: "viem@npm:2.48.4" dependencies: "@noble/curves": "npm:1.9.1" "@noble/hashes": "npm:1.8.0" @@ -15057,14 +15077,14 @@ __metadata: "@scure/bip39": "npm:1.6.0" abitype: "npm:1.2.3" isows: "npm:1.0.7" - ox: "npm:0.12.4" + ox: "npm:0.14.20" ws: "npm:8.18.3" peerDependencies: typescript: ">=5.0.4" peerDependenciesMeta: typescript: optional: true - checksum: 10/dd763503c9fc7c3c2908f8cd403f375a0c313d0ded7aeeef87e1672553fc75cca070ed02e2d811ccc5d3cfb7a589be23e45cb147a556a0a0751adbb3f77be265 + checksum: 10/79ab1c8941013e1b4d12ef0bd7fcca6108cfc078b669cc02ae5a08c94d4e3b6de182071cfb40fb4e33ddc40b3aa997f3ebb50d269c85512cefcefdce49b193a0 languageName: node linkType: hard From 958282468ea2d24396bbc220de065deeb4742985 Mon Sep 17 00:00:00 2001 From: Pedro Figueiredo Date: Tue, 12 May 2026 11:10:47 +0100 Subject: [PATCH 07/12] Add Across Predict withdraw plumbing (#8759) ## Summary This is PR 1 of 4 in the core stack for Predict withdraws over Across. - Adds `TransactionType.predictAcrossWithdraw`. - Treats `isPostQuote` requests as actionable for Across support checks. - Allows source-chain authorization lists only for post-quote Predict withdraw detection, where the original withdraw is not encoded as an Across destination action. - Generalizes transaction-pay refund documentation from Relay-only language to quote-provider language. ## Stack 1. This PR: plumbing to identify Predict Across withdraws 2. #8760: quote support 3. #8761: submit support 4. #8762: gas payment edge cases ## Validation - `yarn workspace @metamask/transaction-pay-controller run jest --no-coverage src/strategy/across/AcrossStrategy.test.ts` - Full stack validation was run on the final stacked branch: - `yarn changelog:validate` - `yarn workspace @metamask/transaction-pay-controller run test` - `yarn workspace @metamask/transaction-controller run test` --- > [!NOTE] > **Medium Risk** > Changes Across quote eligibility logic to treat `isPostQuote` requests as actionable and to allow EIP-7702 `authorizationList` only for a specific Predict-withdraw post-quote path; misclassification could incorrectly enable/disable Across quoting for some transactions. > > **Overview** > Introduces a new `TransactionType.predictAcrossWithdraw` to tag Predict withdraws that will use Across. > > Updates `AcrossStrategy.supports` to treat `isPostQuote` quote requests as actionable and only accept them when the original transaction is a Predict withdraw, plus adds `hasUnsupportedTransactionAuthorizationList` to block EIP-7702 `authorizationList` usage except for the Predict-withdraw post-quote detection case. Tests were extended to cover post-quote Predict withdraw handling. > > Minor docs/logic tweaks: `isAcrossQuoteRequest` now includes `isPostQuote`, and `refundTo` comments were generalized from Relay-specific wording to quote-provider wording. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit fc6f92e1547a7a24bf5f2b37f9ff00f4d11b2cb9. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- packages/transaction-controller/CHANGELOG.md | 4 ++ packages/transaction-controller/src/types.ts | 5 ++ .../transaction-pay-controller/CHANGELOG.md | 1 + .../strategy/across/AcrossStrategy.test.ts | 54 +++++++++++++++++++ .../src/strategy/across/AcrossStrategy.ts | 17 ++++-- .../src/strategy/across/authorization-list.ts | 30 +++++++++++ .../src/strategy/across/requests.ts | 1 + .../transaction-pay-controller/src/types.ts | 12 ++--- 8 files changed, 113 insertions(+), 11 deletions(-) create mode 100644 packages/transaction-pay-controller/src/strategy/across/authorization-list.ts diff --git a/packages/transaction-controller/CHANGELOG.md b/packages/transaction-controller/CHANGELOG.md index 3c61b2ee26..73ce879b0d 100644 --- a/packages/transaction-controller/CHANGELOG.md +++ b/packages/transaction-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `predictAcrossWithdraw` to the `TransactionType` enum ([#8759](https://github.com/MetaMask/core/pull/8759)) + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) diff --git a/packages/transaction-controller/src/types.ts b/packages/transaction-controller/src/types.ts index 0f07ae1443..541dd360d1 100644 --- a/packages/transaction-controller/src/types.ts +++ b/packages/transaction-controller/src/types.ts @@ -845,6 +845,11 @@ export enum TransactionType { */ predictAcrossDeposit = 'predictAcrossDeposit', + /** + * Withdraw funds for Across quote via Predict. + */ + predictAcrossWithdraw = 'predictAcrossWithdraw', + /** * Buy a position via Predict. * diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index f70e98602d..6710a8d235 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) +- Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) ### Fixed diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts index efe6eb6234..6539a87c93 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.test.ts @@ -197,6 +197,60 @@ describe('AcrossStrategy', () => { ).toBe(true); }); + it('supports post-quote predict withdraw requests with source-chain authorization lists', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + transaction: { + ...TRANSACTION_META_MOCK, + nestedTransactions: [{ type: TransactionType.predictWithdraw }], + txParams: { + ...TRANSACTION_META_MOCK.txParams, + authorizationList: [{ address: '0xabc' as Hex }], + data: '0x12345678' as Hex, + to: '0xdef' as Hex, + }, + } as TransactionMeta, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(true); + }); + + it('does not support post-quote requests outside predict withdraw', () => { + const strategy = new AcrossStrategy(); + expect( + strategy.supports({ + ...baseRequest, + requests: [ + { + from: '0xabc' as Hex, + isPostQuote: true, + sourceBalanceRaw: '100', + sourceChainId: '0x1' as Hex, + sourceTokenAddress: '0xabc' as Hex, + sourceTokenAmount: '100', + targetAmountMinimum: '0', + targetChainId: '0x2' as Hex, + targetTokenAddress: '0xdef' as Hex, + }, + ], + }), + ).toBe(false); + }); + it('returns false for unsupported perps deposits', () => { const strategy = new AcrossStrategy(); expect( diff --git a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts index c54cde6187..fc141acfa0 100644 --- a/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts +++ b/packages/transaction-pay-controller/src/strategy/across/AcrossStrategy.ts @@ -8,9 +8,11 @@ import type { TransactionPayQuote, } from '../../types'; import { getPayStrategiesConfig } from '../../utils/feature-flags'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; import { getAcrossDestination } from './across-actions'; import { getAcrossQuotes } from './across-quotes'; import { submitAcrossQuotes } from './across-submit'; +import { hasUnsupportedTransactionAuthorizationList } from './authorization-list'; import { isSupportedAcrossPerpsDepositRequest } from './perps'; import { isAcrossQuoteRequest } from './requests'; import type { AcrossQuote } from './types'; @@ -52,15 +54,20 @@ export class AcrossStrategy implements PayStrategy { } } - // Across cannot submit EIP-7702 authorization lists. This pre-quote check - // catches transactions where the authorization list is already present. - // First-time 7702 upgrades discovered during gas planning are handled in - // `checkQuoteSupport` below. - if (request.transaction.txParams?.authorizationList?.length) { + if ( + hasUnsupportedTransactionAuthorizationList( + request.transaction, + actionableRequests, + ) + ) { return false; } return actionableRequests.every((singleRequest) => { + if (singleRequest.isPostQuote) { + return isPredictWithdrawTransaction(request.transaction); + } + try { getAcrossDestination(request.transaction, singleRequest); return true; diff --git a/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts new file mode 100644 index 0000000000..c157098458 --- /dev/null +++ b/packages/transaction-pay-controller/src/strategy/across/authorization-list.ts @@ -0,0 +1,30 @@ +import type { TransactionMeta } from '@metamask/transaction-controller'; + +import type { QuoteRequest } from '../../types'; +import { isPredictWithdrawTransaction } from '../../utils/transaction'; + +/** + * Check whether an authorization list on the original transaction is unsupported by Across. + * + * Predict withdraw post-quote requests do not use Across destination actions; + * the original withdrawal is submitted by MetaMask on the source chain before + * the Across deposit leg. That keeps a source-chain authorization list out of + * Across' post-swap action payload. + * + * @param transaction - Original transaction metadata. + * @param requests - Across quote requests. + * @returns `true` if the authorization list should block Across. + */ +export function hasUnsupportedTransactionAuthorizationList( + transaction: TransactionMeta, + requests: QuoteRequest[], +): boolean { + if (!transaction.txParams?.authorizationList?.length) { + return false; + } + + return ( + !isPredictWithdrawTransaction(transaction) || + requests.some((request) => request.isPostQuote !== true) + ); +} diff --git a/packages/transaction-pay-controller/src/strategy/across/requests.ts b/packages/transaction-pay-controller/src/strategy/across/requests.ts index 77b967af56..f662dc7ae6 100644 --- a/packages/transaction-pay-controller/src/strategy/across/requests.ts +++ b/packages/transaction-pay-controller/src/strategy/across/requests.ts @@ -3,6 +3,7 @@ import type { QuoteRequest } from '../../types'; export function isAcrossQuoteRequest(request: QuoteRequest): boolean { return ( request.isMaxAmount === true || + request.isPostQuote === true || (request.targetAmountMinimum !== undefined && request.targetAmountMinimum !== '0') ); diff --git a/packages/transaction-pay-controller/src/types.ts b/packages/transaction-pay-controller/src/types.ts index fbee108053..f1e26a291f 100644 --- a/packages/transaction-pay-controller/src/types.ts +++ b/packages/transaction-pay-controller/src/types.ts @@ -123,8 +123,8 @@ export type TransactionConfig = { isPostQuote?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. Use this for post-quote flows where the user's funds originate * from a smart contract account (e.g. Predict Safe proxy) so that refunds * go back to that account rather than the EOA. @@ -224,8 +224,8 @@ export type TransactionData = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; @@ -403,8 +403,8 @@ export type QuoteRequest = { isHyperliquidSource?: boolean; /** - * Optional address to receive refunds if the Relay transaction fails. - * When set, overrides the default refund recipient (EOA) in the Relay quote + * Optional address to receive refunds if the quote provider transaction fails. + * When set, overrides the default refund recipient (EOA) in the quote * request. */ refundTo?: Hex; From 1e5443a2cce334940ca2e6293da236d171a65947 Mon Sep 17 00:00:00 2001 From: Jyoti Puri Date: Tue, 12 May 2026 15:44:48 +0530 Subject: [PATCH 08/12] fix: transaction execution when accountOverride is present for postquote transaction (#8615) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Explanation In case of postquote transaction with accountOverride add delegation to execute quote on behalf of the override account. ## References Related to https://consensyssoftware.atlassian.net/browse/CONF-1323 ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [x] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Changes the Relay post-quote submission path to swap a prepended original tx for a delegation tx when `accountOverride` is used, affecting how batched transactions are constructed and submitted. Incorrect delegation construction or account-detection could break post-quote execution for overridden accounts. > > **Overview** > Fixes Relay *post-quote* submission when `accountOverride` is present by detecting `from` divergence and **replacing the prepended original transaction** with a delegation transaction built via `TransactionPayController:getDelegationTransaction`. > > Adds `buildDelegatedOriginalParams` to fetch and inject the delegation tx params, updates batching logic accordingly, and extends `relay-submit.test.ts` with coverage ensuring the delegation is requested/used only when an override is set. Updates the package changelog to document the fix. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f93b79e838ca542a5e349da48116bf473733f166. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --------- Co-authored-by: Ömer Göktuğ Poyraz --- .../transaction-pay-controller/CHANGELOG.md | 1 + .../src/strategy/relay/relay-submit.test.ts | 67 +++++++++++++++++++ .../src/strategy/relay/relay-submit.ts | 67 +++++++++++++++---- 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index 6710a8d235..ca0159a6cc 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Predict same-chain withdraw quote no longer falls back to block-gas-limit (~30M+) on swap-only Relay routes ([#8735](https://github.com/MetaMask/core/pull/8735)) - `fromOverride = Safe proxy` is now gated on the route having a `deposit` step. Same-chain destinations route through DEX swap aggregators that reject contract callers (anti-MEV `msg.sender == tx.origin` checks etc.) — for those, the relay params' EOA `from` is used so simulation succeeds. - Gas-fee-token lookup still uses the Safe proxy for ALL Predict withdraws (gated on `isPredictWithdraw && refundTo`), preserving the gasless flow for users who hold pUSD in the Safe but no native POL on the EOA. +- Fix post-quote relay submission when `accountOverride` is set by replacing the prepended original transaction with a delegation transaction so the override account can submit it ([#8615](https://github.com/MetaMask/core/pull/8615)) ## [22.3.0] diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts index f93a005c20..eb47e5a6a0 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.test.ts @@ -1013,6 +1013,73 @@ describe('Relay Submit Utils', () => { ); }); + describe('with accountOverride', () => { + const ACCOUNT_OVERRIDE_MOCK = '0xaccountOverride' as Hex; + const DELEGATION_TO_MOCK = '0xdelegationManager' as Hex; + const DELEGATION_DATA_MOCK = '0xdelegationdata' as Hex; + const DELEGATION_VALUE_MOCK = '0x0' as Hex; + + beforeEach(() => { + request.quotes[0].request.from = ACCOUNT_OVERRIDE_MOCK; + getDelegationTransactionMock.mockResolvedValue({ + data: DELEGATION_DATA_MOCK, + to: DELEGATION_TO_MOCK, + value: DELEGATION_VALUE_MOCK, + }); + }); + + it('passes the original transaction through to getDelegationTransaction', async () => { + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).toHaveBeenCalledTimes(1); + expect(getDelegationTransactionMock).toHaveBeenCalledWith({ + transaction: expect.objectContaining({ + id: ORIGINAL_TRANSACTION_ID_MOCK, + txParams: expect.objectContaining({ + from: FROM_MOCK, + to: '0xrecipient', + data: '0xorigdata', + value: '0x100', + }), + }), + }); + }); + + it('uses the delegation result as the first batch tx', async () => { + await submitRelayQuotes(request); + + expect(addTransactionBatchMock).toHaveBeenCalledTimes(1); + expect(addTransactionBatchMock).toHaveBeenCalledWith( + expect.objectContaining({ + from: ACCOUNT_OVERRIDE_MOCK, + transactions: [ + expect.objectContaining({ + params: expect.objectContaining({ + data: DELEGATION_DATA_MOCK, + to: DELEGATION_TO_MOCK, + value: DELEGATION_VALUE_MOCK, + }), + type: TransactionType.simpleSend, + }), + expect.objectContaining({ + params: expect.objectContaining({ + data: '0x1234', + to: '0xfedcb', + }), + type: TransactionType.relayDeposit, + }), + ], + }), + ); + }); + }); + + it('does not call getDelegationTransaction when accountOverride is not set', async () => { + await submitRelayQuotes(request); + + expect(getDelegationTransactionMock).not.toHaveBeenCalled(); + }); + it('activates 7702 mode with single combined post-quote gas limit', async () => { request.quotes[0].original.metamask.gasLimits = [203093]; request.quotes[0].original.metamask.is7702 = true; diff --git a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts index 0082b6af66..53165cb4af 100644 --- a/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts +++ b/packages/transaction-pay-controller/src/strategy/relay/relay-submit.ts @@ -343,20 +343,29 @@ async function submitTransactions( // For post-quote flows, prepend the original transaction so it gets // included in the batch alongside the relay deposit(s). // This always results in multiple params, so it takes the batch path. + // When an accountOverride is set (detected by `from` divergence between the + // quote and the original tx), the override account does not directly hold + // the funds for the original call, so the prepended tx is replaced with a + // delegation tx that redeems the original call on its behalf. const { isPostQuote } = quote.request; - - const allParams = - isPostQuote && transaction.txParams.to - ? [ - { - data: transaction.txParams.data as Hex | undefined, - from: transaction.txParams.from, - to: transaction.txParams.to, - value: transaction.txParams.value as Hex | undefined, - } as TransactionParams, - ...normalizedParams, - ] - : normalizedParams; + const hasAccountOverride = + quote.request.from.toLowerCase() !== + (transaction.txParams.from as Hex).toLowerCase(); + + let allParams = normalizedParams; + + if (isPostQuote && transaction.txParams.to) { + const prependedParams = hasAccountOverride + ? await buildDelegatedOriginalParams(transaction, messenger) + : ({ + data: transaction.txParams.data as Hex | undefined, + from: transaction.txParams.from, + to: transaction.txParams.to, + value: transaction.txParams.value as Hex | undefined, + } as TransactionParams); + + allParams = [prependedParams, ...normalizedParams]; + } if (quote.original.metamask.isExecute) { return await submitViaRelayExecute( @@ -376,6 +385,38 @@ async function submitTransactions( ); } +/** + * Build TransactionParams for a delegation that redeems the original + * post-quote transaction on behalf of the override account. Used when the + * override account cannot execute the original call directly. + * + * The original tx is already on the correct chain and from the money + * account, so it can be passed through to `getDelegationTransaction` + * unchanged. + * + * @param transaction - Original transaction meta to be redeemed. + * @param messenger - Controller messenger. + * @returns Transaction params for the delegation tx. + */ +async function buildDelegatedOriginalParams( + transaction: TransactionMeta, + messenger: TransactionPayControllerMessenger, +): Promise { + const delegation = await messenger.call( + 'TransactionPayController:getDelegationTransaction', + { transaction }, + ); + + log('Delegation result for prepended original tx', delegation); + + return { + data: delegation.data, + from: transaction.txParams.from as Hex, + to: delegation.to, + value: delegation.value, + }; +} + /** * Submit source transactions via Relay's /execute endpoint. * From 7302bf7ef3bbaa27fdfa13de84dbddb4b176183e Mon Sep 17 00:00:00 2001 From: Gauthier Petetin Date: Tue, 12 May 2026 13:07:42 +0200 Subject: [PATCH 09/12] feat(analytics-controller): persist analyticsId in controller state (#8542) ## Explanation Set analyticsId metadata persist flag to true so persisted stores include the identifier alongside optedIn. This is needed by Extension, where we want to move the ID from MetaMetricsController to AnalyticsController. ## References None ## Checklist - [x] I've updated the test suite for new or updated code as appropriate - [x] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [x] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Changes persistence behavior for `analyticsId`, which affects how a user identifier is saved/restored across sessions and could impact client migrations or state storage expectations. > > **Overview** > **`analyticsId` is now persisted by `AnalyticsController`** by marking it `persist: true` in state metadata, so persisted controller compositions save/restore it alongside `optedIn`. > > Docs and JSDoc are updated to reflect the new persistence contract (platform supplies the initial UUIDv4, then the persisted store retains it), and tests add explicit coverage for metadata-derived outputs (debug snapshots, state logs, persisted state, and UI-exposed state). > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 12136bf9d3bc2b7c5d301efc03abb9b90b873269. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- packages/analytics-controller/CHANGELOG.md | 1 + packages/analytics-controller/README.md | 20 +---- .../src/AnalyticsController.test.ts | 83 +++++++++++++++++++ .../src/AnalyticsController.ts | 16 ++-- 4 files changed, 93 insertions(+), 27 deletions(-) diff --git a/packages/analytics-controller/CHANGELOG.md b/packages/analytics-controller/CHANGELOG.md index 1078df2b0c..016e0fc8f9 100644 --- a/packages/analytics-controller/CHANGELOG.md +++ b/packages/analytics-controller/CHANGELOG.md @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Mark `analyticsId` as persisted (`persist: true`) in `AnalyticsController` state metadata so it is saved and restored with `optedIn` when using a persisted controller composition ([#8542](https://github.com/MetaMask/core/pull/8542)) - Bump `@metamask/messenger` from `^1.0.0` to `^1.2.0` ([#8364](https://github.com/MetaMask/core/pull/8364), [#8373](https://github.com/MetaMask/core/pull/8373), [#8632](https://github.com/MetaMask/core/pull/8632)) - Bump `@metamask/base-controller` from `^9.0.1` to `^9.1.0` ([#8457](https://github.com/MetaMask/core/pull/8457)) diff --git a/packages/analytics-controller/README.md b/packages/analytics-controller/README.md index f4979175b7..0fb6d1b968 100644 --- a/packages/analytics-controller/README.md +++ b/packages/analytics-controller/README.md @@ -14,32 +14,16 @@ or The AnalyticsController provides a unified interface for tracking analytics events, identifying users, and managing analytics preferences. It delegates client platform-specific implementation to an `AnalyticsPlatformAdapter` and integrates with the MetaMask messenger system for inter-controller communication. -## Client Platform-Managed Storage - -> [!NOTE] -> "Client platform" means mobile or extension - -The controller does not persist state internally. The client platform is responsible for loading and persisting analytics settings. This design enables: - -- **Early access**: The client platform can read the `analyticsId` before the controller is initialized, useful for other controllers or early startup code -- **Resilience**: Storing analytics settings separately from main state protects them from state corruption, allowing analytics to continue working even when main state is corrupted - -Load settings from storage **before** initializing the controller, then subscribe to `AnalyticsController:stateChange` events to persist any state changes. - ## State | Field | Type | Description | Persisted | | ------------- | --------- | --------------------------------------------- | --------- | -| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | No | +| `analyticsId` | `string` | UUIDv4 identifier (client platform-generated) | Yes | | `optedIn` | `boolean` | User opt-in status | Yes | -### Why `analyticsId` Has No Default - -The `analyticsId` uniquely identifies the user. If the controller generated a new ID on each boot, the ID would be ineffective. The client platform must generate a UUID on first run, persist it, and provide it to the controller constructor. - ### Client Platform Responsibilities -1. **Generate UUID on first run**: Use `uuid` package or client platform equivalent +1. **Generate or migrate an initial `analyticsId`**: Use the `uuid` package or client platform equivalent for new installs, or migrate an existing MetaMetrics identifier when available. The controller validates this value as a UUIDv4, but does not create a default ID. 2. **Load state before controller init**: Read from storage, provide to constructor 3. **Subscribe to state changes**: Persist changes to isolated storage 4. **Persist to isolated storage**: Keep analytics settings separate from main state (protects against state corruption) diff --git a/packages/analytics-controller/src/AnalyticsController.test.ts b/packages/analytics-controller/src/AnalyticsController.test.ts index e012047a27..16744874a6 100644 --- a/packages/analytics-controller/src/AnalyticsController.test.ts +++ b/packages/analytics-controller/src/AnalyticsController.test.ts @@ -1,3 +1,4 @@ +import { deriveStateFromMetadata } from '@metamask/base-controller'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { MockAnyNamespace } from '@metamask/messenger'; @@ -148,6 +149,88 @@ describe('AnalyticsController', () => { }); }); + describe('metadata', () => { + const metadataFixtureState: AnalyticsControllerState = { + optedIn: true, + analyticsId: '6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d', + }; + + it('includes expected state in debug snapshots', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInDebugSnapshot', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('includes expected state in state logs', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'includeInStateLogs', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('persists expected state', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'persist', + ), + ).toMatchInlineSnapshot(` + { + "analyticsId": "6ba7b810-9dad-41d4-80b5-0c4f5a7c1e2d", + "optedIn": true, + } + `); + }); + + it('exposes expected state to UI', async () => { + const { controller } = await setupController({ + state: metadataFixtureState, + }); + + expect( + deriveStateFromMetadata( + controller.state, + controller.metadata, + 'usedInUi', + ), + ).toMatchInlineSnapshot(` + { + "optedIn": true, + } + `); + }); + }); + describe('isValidUUIDv4', () => { it('returns true for valid UUIDv4', () => { expect(isValidUUIDv4('550e8400-e29b-41d4-a716-446655440000')).toBe(true); diff --git a/packages/analytics-controller/src/AnalyticsController.ts b/packages/analytics-controller/src/AnalyticsController.ts index 8f87062be9..182386d7ba 100644 --- a/packages/analytics-controller/src/AnalyticsController.ts +++ b/packages/analytics-controller/src/AnalyticsController.ts @@ -65,10 +65,8 @@ export function getDefaultAnalyticsControllerState(): Omit< /** * The metadata for each property in {@link AnalyticsControllerState}. * - * Note: `optedIn` is persisted by the controller (`persist: true`). - * `analyticsId` is persisted by the platform (`persist: false`) and provided - * via initial state. The platform should subscribe to `stateChange` events - * to persist any state changes. + * Both `optedIn` and `analyticsId` are persisted (`persist: true`). + * The platform must supply a valid UUIDv4 `analyticsId` on first run. */ const analyticsControllerMetadata = { optedIn: { @@ -79,7 +77,7 @@ const analyticsControllerMetadata = { }, analyticsId: { includeInStateLogs: true, - persist: false, + persist: true, includeInDebugSnapshot: true, usedInUi: false, }, @@ -151,7 +149,8 @@ export type AnalyticsControllerMessenger = Messenger< export type AnalyticsControllerOptions = { /** * Initial controller state. Must include a valid UUIDv4 `analyticsId`. - * The platform is responsible for generating and persisting the analyticsId. + * The platform is responsible for generating the ID on first run. + * It is then persisted with controller state when using a persisted store. */ state: AnalyticsControllerState; /** @@ -181,9 +180,8 @@ export type AnalyticsControllerOptions = { * messenger system to allow other controllers and components to track analytics events. * It delegates platform-specific implementation to an {@link AnalyticsPlatformAdapter}. * - * Note: The controller persists `optedIn` internally. The `analyticsId` is persisted - * by the platform and must be provided via initial state. The platform should subscribe - * to `AnalyticsController:stateChange` events to persist any state changes. + * The controller persists `optedIn` and `analyticsId` when composed with a persisted + * store. The platform must supply a valid `analyticsId` on first launch. */ export class AnalyticsController extends BaseController< 'AnalyticsController', From 857b161ed0286a0c892f1965279e2ea72bba96b4 Mon Sep 17 00:00:00 2001 From: maxime-oe Date: Tue, 12 May 2026 14:14:09 +0200 Subject: [PATCH 10/12] Revert "Release/976.0.0 (#8768)" (#8771) This reverts commit 639fc11633ecb520fe2264ea028e130ecb10bdf2. ## Explanation ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Low Risk** > Low risk: this PR mainly reverts package version numbers and dependency bumps, with no functional code changes; risk is limited to release/versioning consistency across packages. > > **Overview** > Reverts the prior `976.0.0` release version bumps across the monorepo, rolling back workspace/package versions for `@metamask/assets-controller`, `@metamask/assets-controllers`, `@metamask/bridge-controller`, `@metamask/bridge-status-controller`, and `@metamask/transaction-pay-controller`, along with their internal dependency ranges. > > Updates associated `CHANGELOG.md` files by removing the newly introduced release sections and adjusting the *Unreleased* compare links to point back to the previous released versions, and updates `yarn.lock` to match the reverted dependency versions. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit aefd5b1d54c8f445fe40cc2ecc2dda1675494ffe. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- packages/assets-controller/CHANGELOG.md | 8 ++----- packages/assets-controller/package.json | 4 ++-- packages/assets-controllers/CHANGELOG.md | 8 +------ packages/assets-controllers/package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 7 +----- packages/bridge-controller/package.json | 6 ++--- .../bridge-status-controller/CHANGELOG.md | 6 +---- .../bridge-status-controller/package.json | 4 ++-- .../transaction-pay-controller/CHANGELOG.md | 17 ++++--------- .../transaction-pay-controller/package.json | 10 ++++---- yarn.lock | 24 +++++++++---------- 12 files changed, 36 insertions(+), 62 deletions(-) diff --git a/package.json b/package.json index ab37266a28..d82c93d5f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "976.0.0", + "version": "975.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index 8bf3c45009..a77b975415 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,13 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [7.1.0] - ### Changed -- Update `RpcDataSource` to prevent native `getEthBalance` fetching for Tempo chains ([#8638](https://github.com/MetaMask/core/pull/8638)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) -- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) +- Update `RpcDataSource` to prevent native `getEthBalance` fetching for Tempo chains ([#8638](https://github.com/MetaMask/core/pull/8638)) ## [7.0.1] @@ -471,8 +468,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.0...HEAD -[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...@metamask/assets-controller@7.1.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...HEAD [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.0...@metamask/assets-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...@metamask/assets-controller@7.0.0 [6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 6e2d6bf18b..6e5975d847 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "7.1.0", + "version": "7.0.1", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,7 +58,7 @@ "@ethersproject/providers": "^5.7.0", "@metamask/account-tree-controller": "^7.3.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controllers": "^106.1.0", + "@metamask/assets-controllers": "^106.0.1", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^12.0.0", diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index a990368966..1c9c19f1fb 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -15,11 +15,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `getTrendingTokens` now accepts `sort` instead of `sortBy` to match the API parameter name ([#8729](https://github.com/MetaMask/core/pull/8729)) - `getTrendingTokens` and `getTrendingTokensURL` now accept arbitrary query parameters via an index signature on `TrendingTokensQueryParams`, allowing new API parameters to pass through without a core release ([#8729](https://github.com/MetaMask/core/pull/8729)) - -## [106.1.0] - -### Changed - - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) - Modify `SPOT_PRICES_SUPPORT_INFO` entries for Tempo chains (`0x1079` and `0xa5bf`) ([#8638](https://github.com/MetaMask/core/pull/8638)) @@ -3076,8 +3071,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.1.0...HEAD -[106.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...@metamask/assets-controllers@106.1.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...HEAD [106.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...@metamask/assets-controllers@106.0.1 [106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0 [105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index cd564ec5a6..3030339eca 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "106.1.0", + "version": "106.0.1", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 8562d62508..ec8f455eaf 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,13 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [72.1.0] - ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) -- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) -- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [72.0.2] @@ -1462,8 +1458,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.1.0...HEAD -[72.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...@metamask/bridge-controller@72.1.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...HEAD [72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.1...@metamask/bridge-controller@72.0.2 [72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.0...@metamask/bridge-controller@72.0.1 [72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...@metamask/bridge-controller@72.0.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 168d488cf7..10b6b22511 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "72.1.0", + "version": "72.0.2", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controller": "^7.1.0", - "@metamask/assets-controllers": "^106.1.0", + "@metamask/assets-controller": "^7.0.1", + "@metamask/assets-controllers": "^106.0.1", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 0152b893d5..a4a502ba71 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,12 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [71.2.0] - ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) -- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) ## [71.1.2] @@ -1181,8 +1178,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.2.0...HEAD -[71.2.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...@metamask/bridge-status-controller@71.2.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...HEAD [71.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.1...@metamask/bridge-status-controller@71.1.2 [71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.0...@metamask/bridge-status-controller@71.1.1 [71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.0.0...@metamask/bridge-status-controller@71.1.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index c55ccc9997..aedf53d5dc 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "71.2.0", + "version": "71.1.2", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -54,7 +54,7 @@ "dependencies": { "@metamask/accounts-controller": "^38.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.1.0", + "@metamask/bridge-controller": "^72.0.2", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index ca0159a6cc..daa407390a 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) - Add Across strategy plumbing to identify post-quote Predict withdraw requests ([#8759](https://github.com/MetaMask/core/pull/8759)) +### Changed + +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) + ### Fixed - Predict same-chain withdraw quote no longer falls back to block-gas-limit (~30M+) on swap-only Relay routes ([#8735](https://github.com/MetaMask/core/pull/8735)) @@ -19,16 +23,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Gas-fee-token lookup still uses the Safe proxy for ALL Predict withdraws (gated on `isPredictWithdraw && refundTo`), preserving the gasless flow for users who hold pUSD in the Safe but no native POL on the EOA. - Fix post-quote relay submission when `accountOverride` is set by replacing the prepended original transaction with a delegation transaction so the override account can submit it ([#8615](https://github.com/MetaMask/core/pull/8615)) -## [22.3.0] - -### Changed - -- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) -- Bump `@metamask/assets-controllers` from `^106.0.1` to `^106.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) -- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) -- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.1.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) -- Bump `@metamask/bridge-status-controller` from `^71.1.2` to `^71.2.0` ([#8768](https://github.com/MetaMask/core/pull/8768)) - ## [22.2.0] ### Changed @@ -856,8 +850,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.0...HEAD -[22.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...@metamask/transaction-pay-controller@22.3.0 +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...HEAD [22.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.1.0...@metamask/transaction-pay-controller@22.2.0 [22.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.2...@metamask/transaction-pay-controller@22.1.0 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.1...@metamask/transaction-pay-controller@22.0.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index 817833f55c..c69f20d773 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "22.3.0", + "version": "22.2.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,11 +57,11 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^7.1.0", - "@metamask/assets-controllers": "^106.1.0", + "@metamask/assets-controller": "^7.0.1", + "@metamask/assets-controllers": "^106.0.1", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.1.0", - "@metamask/bridge-status-controller": "^71.2.0", + "@metamask/bridge-controller": "^72.0.2", + "@metamask/bridge-status-controller": "^71.1.2", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/messenger": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index 425954d381..f01f1a91cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^7.1.0, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^7.0.1, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2777,7 +2777,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.3.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controllers": "npm:^106.1.0" + "@metamask/assets-controllers": "npm:^106.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^106.1.0, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^106.0.1, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -3015,7 +3015,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^72.1.0, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^72.0.2, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -3025,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controller": "npm:^7.1.0" - "@metamask/assets-controllers": "npm:^106.1.0" + "@metamask/assets-controller": "npm:^7.0.1" + "@metamask/assets-controllers": "npm:^106.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.0.0" @@ -3062,14 +3062,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^71.2.0, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^71.1.2, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: "@metamask/accounts-controller": "npm:^38.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.1.0" + "@metamask/bridge-controller": "npm:^72.0.2" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/keyring-controller": "npm:^25.5.0" @@ -5768,12 +5768,12 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^7.1.0" - "@metamask/assets-controllers": "npm:^106.1.0" + "@metamask/assets-controller": "npm:^7.0.1" + "@metamask/assets-controllers": "npm:^106.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.1.0" - "@metamask/bridge-status-controller": "npm:^71.2.0" + "@metamask/bridge-controller": "npm:^72.0.2" + "@metamask/bridge-status-controller": "npm:^71.1.2" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/messenger": "npm:^1.2.0" From 2d99d755b52039b981a1a92a44d7326237d161ef Mon Sep 17 00:00:00 2001 From: maxime-oe Date: Tue, 12 May 2026 14:56:43 +0200 Subject: [PATCH 11/12] Release/976.0.0 (#8773) ## Explanation ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Medium Risk** > Primarily a version/changelog release bump, but it updates to `@metamask/assets-controllers@107.0.0` which includes a **breaking** API change (`getTrendingTokens` parameter rename) that can affect downstream consumers. > > **Overview** > Updates the repo for the `976.0.0` release by bumping package versions and refreshing changelogs. > > This release rolls forward dependency ranges across controllers (notably `@metamask/assets-controller@7.1.0`, `@metamask/assets-controllers@107.0.0`, `@metamask/bridge-controller@72.0.3`, and `@metamask/bridge-status-controller@71.1.3`) and updates `yarn.lock` accordingly. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit d4802b8eb43a1a9e06172eb1bd3a879094b5b984. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- packages/assets-controller/CHANGELOG.md | 8 +++++-- packages/assets-controller/package.json | 4 ++-- packages/assets-controllers/CHANGELOG.md | 5 +++- packages/assets-controllers/package.json | 2 +- packages/bridge-controller/CHANGELOG.md | 7 +++++- packages/bridge-controller/package.json | 6 ++--- .../bridge-status-controller/CHANGELOG.md | 6 ++++- .../bridge-status-controller/package.json | 4 ++-- .../transaction-pay-controller/CHANGELOG.md | 9 ++++++- .../transaction-pay-controller/package.json | 10 ++++---- yarn.lock | 24 +++++++++---------- 12 files changed, 55 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index d82c93d5f4..ab37266a28 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "975.0.0", + "version": "976.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/assets-controller/CHANGELOG.md b/packages/assets-controller/CHANGELOG.md index a77b975415..66e3eac048 100644 --- a/packages/assets-controller/CHANGELOG.md +++ b/packages/assets-controller/CHANGELOG.md @@ -7,10 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.0] + ### Changed -- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) - Update `RpcDataSource` to prevent native `getEthBalance` fetching for Tempo chains ([#8638](https://github.com/MetaMask/core/pull/8638)) +- Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [7.0.1] @@ -468,7 +471,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Refactor `RpcDataSource` to delegate polling to `BalanceFetcher` and `TokenDetector` services ([#7709](https://github.com/MetaMask/core/pull/7709)) - Refactor `BalanceFetcher` and `TokenDetector` to extend `StaticIntervalPollingControllerOnly` for independent polling management ([#7709](https://github.com/MetaMask/core/pull/7709)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.1.0...HEAD +[7.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.1...@metamask/assets-controller@7.1.0 [7.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@7.0.0...@metamask/assets-controller@7.0.1 [7.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.4.0...@metamask/assets-controller@7.0.0 [6.4.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controller@6.3.0...@metamask/assets-controller@6.4.0 diff --git a/packages/assets-controller/package.json b/packages/assets-controller/package.json index 6e5975d847..ab2bcf2731 100644 --- a/packages/assets-controller/package.json +++ b/packages/assets-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controller", - "version": "7.0.1", + "version": "7.1.0", "description": "Tracks assets balances/prices and handles token detection across all digital assets", "keywords": [ "Ethereum", @@ -58,7 +58,7 @@ "@ethersproject/providers": "^5.7.0", "@metamask/account-tree-controller": "^7.3.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/client-controller": "^1.0.1", "@metamask/controller-utils": "^12.0.0", diff --git a/packages/assets-controllers/CHANGELOG.md b/packages/assets-controllers/CHANGELOG.md index 1c9c19f1fb..e06912fa6b 100644 --- a/packages/assets-controllers/CHANGELOG.md +++ b/packages/assets-controllers/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [107.0.0] + ### Added - Export new type `TrendingTokensQueryParams` for extensible trending token query parameters ([#8729](https://github.com/MetaMask/core/pull/8729)) @@ -3071,7 +3073,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Use Ethers for AssetsContractController ([#845](https://github.com/MetaMask/core/pull/845)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@107.0.0...HEAD +[107.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.1...@metamask/assets-controllers@107.0.0 [106.0.1]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@106.0.0...@metamask/assets-controllers@106.0.1 [106.0.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.1.0...@metamask/assets-controllers@106.0.0 [105.1.0]: https://github.com/MetaMask/core/compare/@metamask/assets-controllers@105.0.0...@metamask/assets-controllers@105.1.0 diff --git a/packages/assets-controllers/package.json b/packages/assets-controllers/package.json index 3030339eca..4f99321565 100644 --- a/packages/assets-controllers/package.json +++ b/packages/assets-controllers/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/assets-controllers", - "version": "106.0.1", + "version": "107.0.0", "description": "Controllers which manage interactions involving ERC-20, ERC-721, and ERC-1155 tokens (including NFTs)", "keywords": [ "Ethereum", diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index ec8f455eaf..5ab089cb42 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,9 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [72.0.3] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [72.0.2] @@ -1458,7 +1462,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.3...HEAD +[72.0.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.2...@metamask/bridge-controller@72.0.3 [72.0.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.1...@metamask/bridge-controller@72.0.2 [72.0.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@72.0.0...@metamask/bridge-controller@72.0.1 [72.0.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-controller@71.1.1...@metamask/bridge-controller@72.0.0 diff --git a/packages/bridge-controller/package.json b/packages/bridge-controller/package.json index 10b6b22511..bc065c5084 100644 --- a/packages/bridge-controller/package.json +++ b/packages/bridge-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-controller", - "version": "72.0.2", + "version": "72.0.3", "description": "Manages bridge-related quote fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -58,8 +58,8 @@ "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", "@metamask/accounts-controller": "^38.1.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index a4a502ba71..7d2e375e42 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,9 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [71.1.3] + ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.0.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) ## [71.1.2] @@ -1178,7 +1181,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#5317](https://github.com/MetaMask/core/pull/5317)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.3...HEAD +[71.1.3]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.2...@metamask/bridge-status-controller@71.1.3 [71.1.2]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.1...@metamask/bridge-status-controller@71.1.2 [71.1.1]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.1.0...@metamask/bridge-status-controller@71.1.1 [71.1.0]: https://github.com/MetaMask/core/compare/@metamask/bridge-status-controller@71.0.0...@metamask/bridge-status-controller@71.1.0 diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index aedf53d5dc..0788e65aeb 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/bridge-status-controller", - "version": "71.1.2", + "version": "71.1.3", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", @@ -54,7 +54,7 @@ "dependencies": { "@metamask/accounts-controller": "^38.1.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", + "@metamask/bridge-controller": "^72.0.3", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/keyring-controller": "^25.5.0", diff --git a/packages/transaction-pay-controller/CHANGELOG.md b/packages/transaction-pay-controller/CHANGELOG.md index daa407390a..edae0d5fbb 100644 --- a/packages/transaction-pay-controller/CHANGELOG.md +++ b/packages/transaction-pay-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [22.3.0] + ### Added - Add `POLYGON_PUSD_ADDRESS` constant and treat Polymarket pUSD as a Polygon stablecoin in display/fiat-rate logic ([#8735](https://github.com/MetaMask/core/pull/8735)) @@ -15,6 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/assets-controller` from `^7.0.1` to `^7.1.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/assets-controllers` from `^106.0.1` to `^107.0.0` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/bridge-controller` from `^72.0.2` to `^72.0.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) +- Bump `@metamask/bridge-status-controller` from `^71.1.2` to `^71.1.3` ([#8773](https://github.com/MetaMask/core/pull/8773)) ### Fixed @@ -850,7 +856,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release ([#6820](https://github.com/MetaMask/core/pull/6820)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.3.0...HEAD +[22.3.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.2.0...@metamask/transaction-pay-controller@22.3.0 [22.2.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.1.0...@metamask/transaction-pay-controller@22.2.0 [22.1.0]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.2...@metamask/transaction-pay-controller@22.1.0 [22.0.2]: https://github.com/MetaMask/core/compare/@metamask/transaction-pay-controller@22.0.1...@metamask/transaction-pay-controller@22.0.2 diff --git a/packages/transaction-pay-controller/package.json b/packages/transaction-pay-controller/package.json index c69f20d773..9e620f5e1f 100644 --- a/packages/transaction-pay-controller/package.json +++ b/packages/transaction-pay-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/transaction-pay-controller", - "version": "22.2.0", + "version": "22.3.0", "description": "Manages alternate payment strategies to provide required funds for transactions in MetaMask", "keywords": [ "Ethereum", @@ -57,11 +57,11 @@ "@ethersproject/abi": "^5.7.0", "@ethersproject/contracts": "^5.7.0", "@ethersproject/providers": "^5.7.0", - "@metamask/assets-controller": "^7.0.1", - "@metamask/assets-controllers": "^106.0.1", + "@metamask/assets-controller": "^7.1.0", + "@metamask/assets-controllers": "^107.0.0", "@metamask/base-controller": "^9.1.0", - "@metamask/bridge-controller": "^72.0.2", - "@metamask/bridge-status-controller": "^71.1.2", + "@metamask/bridge-controller": "^72.0.3", + "@metamask/bridge-status-controller": "^71.1.3", "@metamask/controller-utils": "^12.0.0", "@metamask/gas-fee-controller": "^26.2.1", "@metamask/messenger": "^1.2.0", diff --git a/yarn.lock b/yarn.lock index f01f1a91cf..c9835a9fea 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2768,7 +2768,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controller@npm:^7.0.1, @metamask/assets-controller@workspace:packages/assets-controller": +"@metamask/assets-controller@npm:^7.1.0, @metamask/assets-controller@workspace:packages/assets-controller": version: 0.0.0-use.local resolution: "@metamask/assets-controller@workspace:packages/assets-controller" dependencies: @@ -2777,7 +2777,7 @@ __metadata: "@ethersproject/providers": "npm:^5.7.0" "@metamask/account-tree-controller": "npm:^7.3.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/client-controller": "npm:^1.0.1" @@ -2815,7 +2815,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/assets-controllers@npm:^106.0.1, @metamask/assets-controllers@workspace:packages/assets-controllers": +"@metamask/assets-controllers@npm:^107.0.0, @metamask/assets-controllers@workspace:packages/assets-controllers": version: 0.0.0-use.local resolution: "@metamask/assets-controllers@workspace:packages/assets-controllers" dependencies: @@ -3015,7 +3015,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-controller@npm:^72.0.2, @metamask/bridge-controller@workspace:packages/bridge-controller": +"@metamask/bridge-controller@npm:^72.0.3, @metamask/bridge-controller@workspace:packages/bridge-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-controller@workspace:packages/bridge-controller" dependencies: @@ -3025,8 +3025,8 @@ __metadata: "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" "@metamask/accounts-controller": "npm:^38.1.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" "@metamask/controller-utils": "npm:^12.0.0" @@ -3062,14 +3062,14 @@ __metadata: languageName: unknown linkType: soft -"@metamask/bridge-status-controller@npm:^71.1.2, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": +"@metamask/bridge-status-controller@npm:^71.1.3, @metamask/bridge-status-controller@workspace:packages/bridge-status-controller": version: 0.0.0-use.local resolution: "@metamask/bridge-status-controller@workspace:packages/bridge-status-controller" dependencies: "@metamask/accounts-controller": "npm:^38.1.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" + "@metamask/bridge-controller": "npm:^72.0.3" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/keyring-controller": "npm:^25.5.0" @@ -5768,12 +5768,12 @@ __metadata: "@ethersproject/abi": "npm:^5.7.0" "@ethersproject/contracts": "npm:^5.7.0" "@ethersproject/providers": "npm:^5.7.0" - "@metamask/assets-controller": "npm:^7.0.1" - "@metamask/assets-controllers": "npm:^106.0.1" + "@metamask/assets-controller": "npm:^7.1.0" + "@metamask/assets-controllers": "npm:^107.0.0" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/bridge-controller": "npm:^72.0.2" - "@metamask/bridge-status-controller": "npm:^71.1.2" + "@metamask/bridge-controller": "npm:^72.0.3" + "@metamask/bridge-status-controller": "npm:^71.1.3" "@metamask/controller-utils": "npm:^12.0.0" "@metamask/gas-fee-controller": "npm:^26.2.1" "@metamask/messenger": "npm:^1.2.0" From 13ec41decaa1a6c0579dcb53be1583934470c3f5 Mon Sep 17 00:00:00 2001 From: John Whiles Date: Tue, 12 May 2026 14:11:42 +0100 Subject: [PATCH 12/12] Release/977.0.0 (#8769) ## Explanation Release new versions of the chomp-api-service and money-account-upgrade-controller ## References ## Checklist - [ ] I've updated the test suite for new or updated code as appropriate - [ ] I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate - [ ] I've communicated my changes to consumers by [updating changelogs for packages I've changed](https://github.com/MetaMask/core/tree/main/docs/processes/updating-changelogs.md) - [ ] I've introduced [breaking changes](https://github.com/MetaMask/core/tree/main/docs/processes/breaking-changes.md) in this PR and have prepared draft pull requests for clients and consumer packages to resolve them --- > [!NOTE] > **Low Risk** > Low risk: this PR only updates package versions, changelogs, and dependency pins/lockfile entries with no code changes. > > **Overview** > Bumps the monorepo version to `977.0.0` and releases `@metamask/chomp-api-service@3.1.0` (documenting the updated retry policy that avoids retrying most 4xx responses). > > Releases `@metamask/money-account-upgrade-controller@2.0.0` and updates it to depend on `@metamask/chomp-api-service@^3.1.0`, with corresponding `CHANGELOG.md` and `yarn.lock` updates. > > Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit aa8e6c46f9e2e858be56b834c9653436e1c10d01. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot). --- package.json | 2 +- packages/chomp-api-service/CHANGELOG.md | 5 ++++- packages/chomp-api-service/package.json | 2 +- packages/money-account-upgrade-controller/CHANGELOG.md | 6 +++++- packages/money-account-upgrade-controller/package.json | 4 ++-- yarn.lock | 4 ++-- 6 files changed, 15 insertions(+), 8 deletions(-) diff --git a/package.json b/package.json index ab37266a28..feb9d66fd0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/core-monorepo", - "version": "976.0.0", + "version": "977.0.0", "private": true, "description": "Monorepo for packages shared between MetaMask clients", "repository": { diff --git a/packages/chomp-api-service/CHANGELOG.md b/packages/chomp-api-service/CHANGELOG.md index b37660b4fe..fdd9182667 100644 --- a/packages/chomp-api-service/CHANGELOG.md +++ b/packages/chomp-api-service/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [3.1.0] + ### Changed - `ChompApiService` no longer retries HTTP requests that fail with a 4xx response (other than 429), since those responses indicate the request itself is at fault and will not be resolved by re-issuing it. 5xx, 429, and non-HTTP errors (network/timeout) continue to be retried. Consumers can still override this by passing a `retryFilterPolicy` via `policyOptions`. ([#8621](https://github.com/MetaMask/core/pull/8621)) @@ -37,7 +39,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ChompApiService` ([#8413](https://github.com/MetaMask/core/pull/8413)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.1...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.1.0...HEAD +[3.1.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.1...@metamask/chomp-api-service@3.1.0 [3.0.1]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@3.0.0...@metamask/chomp-api-service@3.0.1 [3.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@2.0.0...@metamask/chomp-api-service@3.0.0 [2.0.0]: https://github.com/MetaMask/core/compare/@metamask/chomp-api-service@1.0.0...@metamask/chomp-api-service@2.0.0 diff --git a/packages/chomp-api-service/package.json b/packages/chomp-api-service/package.json index 92185a9ed9..1b388da2c1 100644 --- a/packages/chomp-api-service/package.json +++ b/packages/chomp-api-service/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/chomp-api-service", - "version": "3.0.1", + "version": "3.1.0", "description": "Data service for the Chomp API", "keywords": [ "Ethereum", diff --git a/packages/money-account-upgrade-controller/CHANGELOG.md b/packages/money-account-upgrade-controller/CHANGELOG.md index 35862153e4..d5c375aaaf 100644 --- a/packages/money-account-upgrade-controller/CHANGELOG.md +++ b/packages/money-account-upgrade-controller/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [2.0.0] + ### Added - Add remaining steps in money account upgrade process ([#8621](https://github.com/MetaMask/core/pull/8621)) @@ -17,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** `init()` now takes a `{ chainId, boringVaultAddress }` object instead of an `InitConfig`. The EIP-7702 delegator implementation and caveat enforcer addresses are resolved from `@metamask/delegation-deployments` for the target chain; `init()` throws if the chain is not supported by Delegation Framework 1.3.0. The `InitConfig` type is no longer exported. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Add `@metamask/authenticated-user-storage`, `@metamask/delegation-controller`, `@metamask/delegation-core`, and `@metamask/delegation-deployments` as dependencies. ([#8621](https://github.com/MetaMask/core/pull/8621)) - Bump `@metamask/network-controller` from `^31.0.0` to `^31.1.0` ([#8765](https://github.com/MetaMask/core/pull/8765)) +- Bump `@metamask/chomp-api-service` from `^3.0.1` to `^3.1.0` ([#8769](https://github.com/MetaMask/core/pull/8769)) ### Fixed @@ -76,7 +79,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `MoneyAccountUpgradeController` with `upgradeAccount` method ([#8426](https://github.com/MetaMask/core/pull/8426)) -[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.2...HEAD +[Unreleased]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@2.0.0...HEAD +[2.0.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.2...@metamask/money-account-upgrade-controller@2.0.0 [1.3.2]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.1...@metamask/money-account-upgrade-controller@1.3.2 [1.3.1]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.3.0...@metamask/money-account-upgrade-controller@1.3.1 [1.3.0]: https://github.com/MetaMask/core/compare/@metamask/money-account-upgrade-controller@1.2.0...@metamask/money-account-upgrade-controller@1.3.0 diff --git a/packages/money-account-upgrade-controller/package.json b/packages/money-account-upgrade-controller/package.json index 802cbe10f2..a2a7851848 100644 --- a/packages/money-account-upgrade-controller/package.json +++ b/packages/money-account-upgrade-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/money-account-upgrade-controller", - "version": "1.3.2", + "version": "2.0.0", "description": "MetaMask Money account upgrade controller", "keywords": [ "Ethereum", @@ -55,7 +55,7 @@ "dependencies": { "@metamask/authenticated-user-storage": "^1.0.1", "@metamask/base-controller": "^9.1.0", - "@metamask/chomp-api-service": "^3.0.1", + "@metamask/chomp-api-service": "^3.1.0", "@metamask/delegation-controller": "^3.0.0", "@metamask/delegation-core": "^2.0.0", "@metamask/delegation-deployments": "^1.3.0", diff --git a/yarn.lock b/yarn.lock index c9835a9fea..44e996f678 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3148,7 +3148,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/chomp-api-service@npm:^3.0.1, @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: @@ -4543,7 +4543,7 @@ __metadata: "@metamask/authenticated-user-storage": "npm:^1.0.1" "@metamask/auto-changelog": "npm:^6.1.0" "@metamask/base-controller": "npm:^9.1.0" - "@metamask/chomp-api-service": "npm:^3.0.1" + "@metamask/chomp-api-service": "npm:^3.1.0" "@metamask/delegation-controller": "npm:^3.0.0" "@metamask/delegation-core": "npm:^2.0.0" "@metamask/delegation-deployments": "npm:^1.3.0"