From 4091b04ea41f6903a5b12e01894a5fa9a4753559 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 10 Feb 2026 14:59:16 +0100 Subject: [PATCH 01/23] feat: allow intent typed data in bridge schema --- packages/bridge-controller/src/utils/validators.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index ac6577aa45e..cd608edb1fb 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -11,6 +11,7 @@ import { nullable, optional, enums, + unknown, define, union, assert, @@ -332,6 +333,11 @@ export const IntentSchema = type({ * Optional relayer address responsible for order submission. */ relayer: optional(HexAddressSchema), + + /** + * Optional EIP-712 typed data payload for signing. + */ + typedData: optional(unknown()), }); export const QuoteSchema = type({ From 32df2d0178118cfd960efb5bd920728be697ab89 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 10 Feb 2026 17:29:15 +0100 Subject: [PATCH 02/23] feat: validate intent typedData as JSON --- packages/bridge-controller/src/utils/validators.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index cd608edb1fb..217fb91d67d 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -1,4 +1,5 @@ import { isValidHexAddress } from '@metamask/controller-utils'; +import { JsonStruct } from '@metamask/utils'; import type { Infer } from '@metamask/superstruct'; import { string, @@ -11,7 +12,6 @@ import { nullable, optional, enums, - unknown, define, union, assert, @@ -336,8 +336,9 @@ export const IntentSchema = type({ /** * Optional EIP-712 typed data payload for signing. + * Must be JSON-serializable to satisfy controller state constraints. */ - typedData: optional(unknown()), + typedData: optional(JsonStruct), }); export const QuoteSchema = type({ From f1407291d5d0586cb8e9bfd062b768b9521b53a8 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 10 Feb 2026 17:37:41 +0100 Subject: [PATCH 03/23] feat: enforce typedData shape in intent schema --- .../bridge-controller/src/utils/validators.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 217fb91d67d..00dce08f71a 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -336,9 +336,24 @@ export const IntentSchema = type({ /** * Optional EIP-712 typed data payload for signing. - * Must be JSON-serializable to satisfy controller state constraints. + * Must be JSON-serializable and include required EIP-712 fields. */ - typedData: optional(JsonStruct), + typedData: optional( + type({ + types: record( + string(), + array( + type({ + name: string(), + type: string(), + }), + ), + ), + primaryType: string(), + domain: JsonStruct, + message: JsonStruct, + }), + ), }); export const QuoteSchema = type({ From f930d4f6b4c8ac20985f7c790f14a67666b902fe Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 10 Feb 2026 18:32:48 +0100 Subject: [PATCH 04/23] fix: fix type depth error --- packages/bridge-controller/src/utils/validators.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 00dce08f71a..99b1e997839 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -1,5 +1,4 @@ import { isValidHexAddress } from '@metamask/controller-utils'; -import { JsonStruct } from '@metamask/utils'; import type { Infer } from '@metamask/superstruct'; import { string, @@ -17,6 +16,7 @@ import { assert, pattern, intersection, + any, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -350,8 +350,8 @@ export const IntentSchema = type({ ), ), primaryType: string(), - domain: JsonStruct, - message: JsonStruct, + domain: any(), + message: any(), }), ), }); From 53ab9503098eae75baf53a32e207f3da02d302e5 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 20 Feb 2026 16:46:46 +0100 Subject: [PATCH 05/23] Add bridge-controller changelog entry for intent typedData --- packages/bridge-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 910d2138d67..0abb9bc0c20 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.18.0` ([#8005](https://github.com/MetaMask/core/pull/8005)) - Bump `@metamask/assets-controllers` from `^100.0.1` to `^100.0.2` ([#8004](https://github.com/MetaMask/core/pull/8004)) - Replace `PERCENT_90` with `PERCENT_75` in `InputAmountPreset` enum ([#7997](https://github.com/MetaMask/core/pull/7997)) +- Add optional `typedData` validation to intent quotes for backend-provided EIP-712 signing payload compatibility. ## [67.1.1] From 407b9784c4ffb8db3814af8ee9663e021ceba0d1 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 24 Feb 2026 18:35:30 +0100 Subject: [PATCH 06/23] feat(bridge-status): sign intent typedData in core submitIntent --- .../src/bridge-status-controller.ts | 25 ++++++++++++++++--- .../bridge-status-controller/src/types.ts | 16 ++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index bedf40bffb6..f41a218dd03 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -38,7 +38,7 @@ import type { TransactionParams, } from '@metamask/transaction-controller'; import { numberToHex } from '@metamask/utils'; -import type { Hex } from '@metamask/utils'; +import type { Hex, Json } from '@metamask/utils'; import { IntentStatusManager } from './bridge-status-controller.intent'; import { @@ -1601,18 +1601,19 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; - signature: string; + signature?: string; accountAddress: string; location?: MetaMetricsSwapsEventSource; }): Promise> => { - const { quoteResponse, signature, accountAddress, location } = params; + const { quoteResponse, signature: precomputedSignature, accountAddress, location } = + params; this.messenger.call( 'BridgeController:stopPollingForQuotes', @@ -1662,6 +1663,22 @@ export class BridgeStatusController extends StaticIntervalPollingController { + if (!intent.typedData) { + throw new Error('submitIntent: missing intent typedData'); + } + return this.messenger.call( + 'KeyringController:signTypedMessage', + { + from: accountAddress, + data: intent.typedData as unknown as Json, + }, + 'V4', + ); + })()); + const submissionParams = { srcChainId: chainId.toString(), quoteId: requestId, diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 66f3cb6464c..50c0230a432 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -30,7 +30,7 @@ import type { TransactionControllerTransactionFailedEvent, TransactionMeta, } from '@metamask/transaction-controller'; -import type { CaipAssetType } from '@metamask/utils'; +import type { CaipAssetType, Json } from '@metamask/utils'; import type { BridgeStatusController } from './bridge-status-controller'; import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; @@ -290,6 +290,17 @@ export type BridgeStatusControllerEvents = | BridgeStatusControllerStateChangeEvent | BridgeStatusControllerDestinationTransactionCompletedEvent; +type KeyringControllerSignTypedMessageAction = { + type: 'KeyringController:signTypedMessage'; + handler: ( + msgParams: { + from: string; + data: Json; + }, + version: 'V1' | 'V3' | 'V4', + ) => Promise; +}; + /** * The external actions available to the BridgeStatusController. */ @@ -304,7 +315,8 @@ type AllowedActions = | GetGasFeeState | AccountsControllerGetAccountByAddressAction | RemoteFeatureFlagControllerGetStateAction - | AuthenticationControllerGetBearerToken; + | AuthenticationControllerGetBearerToken + | KeyringControllerSignTypedMessageAction; /** * The external events available to the BridgeStatusController. From d7808754401107e6375cb83de29ca16eb93a3dcd Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 24 Feb 2026 18:43:55 +0100 Subject: [PATCH 07/23] test(bridge-status): cover in-core intent signing path --- .../bridge-status-controller.intent.test.ts | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 30db8d7444a..05d8c0fb7d4 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -535,6 +535,71 @@ describe('BridgeStatusController (intent swaps)', () => { consoleSpy.mockRestore(); }); + it('submitIntent: signs typedData when signature is not provided', async () => { + const { controller, messenger, accountAddress, submitIntentMock } = setup(); + + const orderUid = 'order-uid-signed-in-core-1'; + submitIntentMock.mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); + + const quoteResponse = minimalIntentQuoteResponse(); + quoteResponse.quote.intent.typedData = { + types: {}, + primaryType: 'Order', + domain: {}, + message: {}, + }; + + const originalCallImpl = (messenger.call as jest.Mock).getMockImplementation(); + (messenger.call as jest.Mock).mockImplementation( + (method: string, ...args: any[]) => { + if (method === 'KeyringController:signTypedMessage') { + return '0xautosigned'; + } + return originalCallImpl?.(method, ...args); + }, + ); + + await controller.submitIntent({ + quoteResponse, + accountAddress, + }); + + expect((messenger.call as jest.Mock).mock.calls).toEqual( + expect.arrayContaining([ + [ + 'KeyringController:signTypedMessage', + expect.objectContaining({ + from: accountAddress, + data: quoteResponse.quote.intent.typedData, + }), + 'V4', + ], + ]), + ); + + expect(submitIntentMock.mock.calls[0]?.[0]?.signature).toBe('0xautosigned'); + }); + + it('submitIntent: throws when signature and typedData are both missing', async () => { + const { controller, accountAddress, submitIntentMock } = setup(); + + const quoteResponse = minimalIntentQuoteResponse(); + + await expect( + controller.submitIntent({ + quoteResponse, + accountAddress, + }), + ).rejects.toThrow('submitIntent: missing intent typedData'); + + expect(submitIntentMock).not.toHaveBeenCalled(); + }); + it('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { const { controller, From 00813c7cc8f649adcd15aaefa2aac2407229bdeb Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Tue, 24 Feb 2026 19:10:26 +0100 Subject: [PATCH 08/23] fix bridge status controller lint issues --- .../src/bridge-status-controller.intent.test.ts | 6 ++++-- .../src/bridge-status-controller.ts | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 05d8c0fb7d4..d50cf55c88e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -554,7 +554,9 @@ describe('BridgeStatusController (intent swaps)', () => { message: {}, }; - const originalCallImpl = (messenger.call as jest.Mock).getMockImplementation(); + const originalCallImpl = ( + messenger.call as jest.Mock + ).getMockImplementation(); (messenger.call as jest.Mock).mockImplementation( (method: string, ...args: any[]) => { if (method === 'KeyringController:signTypedMessage') { @@ -569,7 +571,7 @@ describe('BridgeStatusController (intent swaps)', () => { accountAddress, }); - expect((messenger.call as jest.Mock).mock.calls).toEqual( + expect((messenger.call as jest.Mock).mock.calls).toStrictEqual( expect.arrayContaining([ [ 'KeyringController:signTypedMessage', diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index f41a218dd03..7026947bd9a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1612,8 +1612,12 @@ export class BridgeStatusController extends StaticIntervalPollingController> => { - const { quoteResponse, signature: precomputedSignature, accountAddress, location } = - params; + const { + quoteResponse, + signature: precomputedSignature, + accountAddress, + location, + } = params; this.messenger.call( 'BridgeController:stopPollingForQuotes', @@ -1665,7 +1669,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + (await ((): Promise => { if (!intent.typedData) { throw new Error('submitIntent: missing intent typedData'); } From a5f6b744688d01bf938bb06e3efbfc4468daf10c Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Wed, 25 Feb 2026 01:12:07 +0100 Subject: [PATCH 09/23] update bridge-status-controller changelog --- packages/bridge-status-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 5407cce1f01..75d1b1c4787 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` from `^67.1.1` to `^67.2.0` ([#8024](https://github.com/MetaMask/core/pull/8024)) - Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.19.0` ([#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) +- Make `submitIntent` sign intent typed data internally when signature is not provided, keeping support for externally provided signatures. ## [67.0.1] From 9dfbdd27f808fbc03a22af1b6fb9082904444780 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Wed, 25 Feb 2026 09:46:45 +0100 Subject: [PATCH 10/23] update bridge-controller changelog for intent typedData --- packages/bridge-controller/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index f2bda45769a..a459eb2a5bf 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^62.18.0` to `^62.19.0` ([#8031](https://github.com/MetaMask/core/pull/8031)) - Bump `@metamask/assets-controllers` from `^100.0.2` to `^100.0.3` ([#8029](https://github.com/MetaMask/core/pull/8029)) +- Extend quote intent validation to accept optional EIP-712 `typedData` payloads. ## [67.2.0] From 7e2f3d9476add90bfd24ba86ba6d9f7f9a3e48fd Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Wed, 25 Feb 2026 12:25:55 +0100 Subject: [PATCH 11/23] link bridge changelog entries to PR --- packages/bridge-controller/CHANGELOG.md | 2 +- packages/bridge-status-controller/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index a459eb2a5bf..7d826e6f521 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/transaction-controller` from `^62.18.0` to `^62.19.0` ([#8031](https://github.com/MetaMask/core/pull/8031)) - Bump `@metamask/assets-controllers` from `^100.0.2` to `^100.0.3` ([#8029](https://github.com/MetaMask/core/pull/8029)) -- Extend quote intent validation to accept optional EIP-712 `typedData` payloads. +- Extend quote intent validation to accept optional EIP-712 `typedData` payloads ([#7895](https://github.com/MetaMask/core/pull/7895)). ## [67.2.0] diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 75d1b1c4787..4cf0d1e7657 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` from `^67.1.1` to `^67.2.0` ([#8024](https://github.com/MetaMask/core/pull/8024)) - Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.19.0` ([#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) -- Make `submitIntent` sign intent typed data internally when signature is not provided, keeping support for externally provided signatures. +- Make `submitIntent` sign intent typed data internally when signature is not provided, keeping support for externally provided signatures ([#7895](https://github.com/MetaMask/core/pull/7895)). ## [67.0.1] From 60faf466ec56e696f2c67d39efd77d43d4e517c4 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Thu, 26 Feb 2026 18:11:56 +0100 Subject: [PATCH 12/23] Fix lint naming convention for ab_tests extraction --- .../src/bridge-status-controller.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 184f14ef3c4..e0ce6c2e50b 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1838,9 +1838,10 @@ export class BridgeStatusController extends StaticIntervalPollingController[EventName], ): void => { - const eventAbTests = ( - eventProperties as { ab_tests?: Record } | undefined - )?.ab_tests; + const { ab_tests: eventAbTests } = + (eventProperties as + | Record | undefined> + | undefined) ?? {}; const historyAbTests = txMetaId ? this.state.txHistory?.[txMetaId]?.abTests : undefined; From 14720e600baf9f57ad629cf15f695c8f5e6712f6 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 09:59:22 +0100 Subject: [PATCH 13/23] Remove precomputed signature from bridge intent submission --- .../bridge-status-controller.intent.test.ts | 9 +---- .../src/bridge-status-controller.ts | 40 +++++++------------ 2 files changed, 15 insertions(+), 34 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index b11f538f8e4..1f61551b3db 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -401,7 +401,6 @@ describe('BridgeStatusController (intent swaps)', () => { const promise = controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); expect(await promise.catch((error: any) => error)).toStrictEqual( @@ -448,7 +447,6 @@ describe('BridgeStatusController (intent swaps)', () => { await expect( controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }), ).resolves.toBeDefined(); @@ -482,7 +480,6 @@ describe('BridgeStatusController (intent swaps)', () => { const promise = controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); expect(await promise.catch((error: any) => error)).toStrictEqual( @@ -522,7 +519,6 @@ describe('BridgeStatusController (intent swaps)', () => { const result = await controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); @@ -535,7 +531,7 @@ describe('BridgeStatusController (intent swaps)', () => { consoleSpy.mockRestore(); }); - it('submitIntent: signs typedData when signature is not provided', async () => { + it('submitIntent: signs typedData', async () => { const { controller, messenger, accountAddress, submitIntentMock } = setup(); const orderUid = 'order-uid-signed-in-core-1'; @@ -624,7 +620,6 @@ describe('BridgeStatusController (intent swaps)', () => { await controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); @@ -669,7 +664,6 @@ describe('BridgeStatusController (intent swaps)', () => { await controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); @@ -716,7 +710,6 @@ describe('BridgeStatusController (intent swaps)', () => { await controller.submitIntent({ quoteResponse, - signature: '0xsig', accountAddress, }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index e0ce6c2e50b..61c072834be 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1593,12 +1593,11 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; - signature?: string; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; }): Promise> => { - const { - quoteResponse, - signature: precomputedSignature, - accountAddress, - location, - abTests, - } = params; + const { quoteResponse, accountAddress, location, abTests } = params; this.messenger.call( 'BridgeController:stopPollingForQuotes', @@ -1668,21 +1660,17 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - if (!intent.typedData) { - throw new Error('submitIntent: missing intent typedData'); - } - return this.messenger.call( - 'KeyringController:signTypedMessage', - { - from: accountAddress, - data: intent.typedData as unknown as Json, - }, - 'V4', - ); - })()); + if (!intent.typedData) { + throw new Error('submitIntent: missing intent typedData'); + } + const signature = await this.messenger.call( + 'KeyringController:signTypedMessage', + { + from: accountAddress, + data: intent.typedData as unknown as Json, + }, + 'V4', + ); const submissionParams: IntentSubmissionParams = { srcChainId: chainId.toString(), From 2ae849efff836ebd480042227ea5ad9a58a03aa8 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:07:38 +0100 Subject: [PATCH 14/23] Tighten typedData domain/message validation to unknown records --- packages/bridge-controller/src/utils/validators.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 99b1e997839..4e474ccb466 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -16,7 +16,7 @@ import { assert, pattern, intersection, - any, + unknown, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -350,8 +350,8 @@ export const IntentSchema = type({ ), ), primaryType: string(), - domain: any(), - message: any(), + domain: record(string(), unknown()), + message: record(string(), unknown()), }), ), }); From d76397721e276d4010cd6ab6a2d2bbc26e4293e4 Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:11:42 +0100 Subject: [PATCH 15/23] Fix intent tests for required typedData signing path --- .../src/bridge-status-controller.intent.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 1f61551b3db..f15a5d0ebfd 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -69,6 +69,12 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { protocol: 'cowswap', order: { some: 'order' }, settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + typedData: { + types: {}, + primaryType: 'Order', + domain: {}, + message: {}, + }, }, }, sentAmount: { amount: '1', usd: '1' }, @@ -169,6 +175,8 @@ const createMessengerHarness = ( return undefined; case 'GasFeeController:getState': return { gasFeeEstimates: {} }; + case 'KeyringController:signTypedMessage': + return '0xtest-signature'; default: return undefined; } @@ -587,6 +595,7 @@ describe('BridgeStatusController (intent swaps)', () => { const { controller, accountAddress, submitIntentMock } = setup(); const quoteResponse = minimalIntentQuoteResponse(); + quoteResponse.quote.intent.typedData = undefined; await expect( controller.submitIntent({ From 12a3874326260c5cd71c8077f8dd598ce2b1cf6f Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:27:50 +0100 Subject: [PATCH 16/23] fix(bridge-controller): avoid typedData domain/message type recursion --- packages/bridge-controller/src/utils/validators.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 4e474ccb466..201a332767e 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -1,6 +1,7 @@ import { isValidHexAddress } from '@metamask/controller-utils'; import type { Infer } from '@metamask/superstruct'; import { + any, string, boolean, number, @@ -16,7 +17,6 @@ import { assert, pattern, intersection, - unknown, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -350,8 +350,8 @@ export const IntentSchema = type({ ), ), primaryType: string(), - domain: record(string(), unknown()), - message: record(string(), unknown()), + domain: any(), + message: any(), }), ), }); From 3b057ea5f5a4b282891664d85c2013f2f47f21aa Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:29:32 +0100 Subject: [PATCH 17/23] docs(bridge-controller): explain typedData any fallback --- packages/bridge-controller/src/utils/validators.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 201a332767e..09f4a0497e1 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -350,6 +350,9 @@ export const IntentSchema = type({ ), ), primaryType: string(), + // Keep `any()` here: stricter recursive JSON types for EIP-712 domain/message + // trigger TS2321/TS2589 (excessive type instantiation depth) in bridge state + // inference during build. domain: any(), message: any(), }), From 5e001a08334ff39d3aa4ac870686cffac9d3c8cf Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:42:41 +0100 Subject: [PATCH 18/23] refactor(bridge-controller): use record(string(), any()) for typedData --- packages/bridge-controller/src/utils/validators.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 09f4a0497e1..54a3fe38b7c 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -350,11 +350,11 @@ export const IntentSchema = type({ ), ), primaryType: string(), - // Keep `any()` here: stricter recursive JSON types for EIP-712 domain/message - // trigger TS2321/TS2589 (excessive type instantiation depth) in bridge state + // Keep values as `any()` here. Using `unknown()` in this record causes + // TS2321/TS2589 (excessive type instantiation depth) in bridge state // inference during build. - domain: any(), - message: any(), + domain: record(string(), any()), + message: record(string(), any()), }), ), }); From 2039520f6d21e64ced68ba516e92719d4523ef0b Mon Sep 17 00:00:00 2001 From: Oscar Roche Date: Fri, 27 Feb 2026 10:57:15 +0100 Subject: [PATCH 19/23] fix(bridge-status): validate intent typedData before approval --- .../src/bridge-status-controller.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 61c072834be..a57eb295668 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1632,6 +1632,10 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Sun, 1 Mar 2026 22:18:33 +0100 Subject: [PATCH 20/23] fix: review comments and adjust related tests --- .../src/utils/validators.test.ts | 95 ++++++++++++++++++- .../bridge-controller/src/utils/validators.ts | 31 ++---- .../bridge-status-controller/CHANGELOG.md | 2 +- .../bridge-status-controller.intent.test.ts | 20 ---- .../src/bridge-status-controller.ts | 12 +-- .../bridge-status-controller/src/types.ts | 14 +-- 6 files changed, 107 insertions(+), 67 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 06bfeab351e..48c5e53ca42 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -1,4 +1,6 @@ -import { validateFeatureFlagsResponse } from './validators'; +import { is } from '@metamask/superstruct'; + +import { validateFeatureFlagsResponse, IntentSchema } from './validators'; describe('validators', () => { describe('validateFeatureFlagsResponse', () => { @@ -323,4 +325,95 @@ describe('validators', () => { }, ); }); + + describe('IntentSchema', () => { + const validOrder = { + sellToken: '0x0000000000000000000000000000000000000001', + buyToken: '0x0000000000000000000000000000000000000002', + validTo: 1717027200, + appData: 'some-app-data', + appDataHash: '0xabcd', + feeAmount: '100', + kind: 'sell' as const, + partiallyFillable: false, + sellAmount: '1000', + }; + + const validIntent = { + protocol: 'cowswap', + order: validOrder, + typedData: { + domain: { name: 'GPv2Settlement', chainId: 1 }, + message: { sellToken: '0x01', buyToken: '0x02' }, + }, + }; + + it('accepts a valid intent with required fields only', () => { + expect(is(validIntent, IntentSchema)).toBe(true); + }); + + it('accepts intent with optional settlementContract', () => { + expect( + is( + { + ...validIntent, + settlementContract: + '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + }, + IntentSchema, + ), + ).toBe(true); + }); + + it('rejects intent without typedData', () => { + const { typedData: _, ...intentWithoutTypedData } = validIntent; + expect(is(intentWithoutTypedData, IntentSchema)).toBe(false); + }); + + it('rejects intent with typedData missing domain', () => { + expect( + is( + { + ...validIntent, + typedData: { message: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing message', () => { + expect( + is( + { + ...validIntent, + typedData: { domain: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent without protocol', () => { + const { protocol: _, ...intentWithoutProtocol } = validIntent; + expect(is(intentWithoutProtocol, IntentSchema)).toBe(false); + }); + + it('rejects intent without order', () => { + const { order: _, ...intentWithoutOrder } = validIntent; + expect(is(intentWithoutOrder, IntentSchema)).toBe(false); + }); + + it('accepts intent with empty typedData records', () => { + expect( + is( + { + ...validIntent, + typedData: { domain: {}, message: {} }, + }, + IntentSchema, + ), + ).toBe(true); + }); + }); }); diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 54a3fe38b7c..457b85d3475 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -329,34 +329,17 @@ export const IntentSchema = type({ */ settlementContract: optional(HexAddressSchema), - /** - * Optional relayer address responsible for order submission. - */ - relayer: optional(HexAddressSchema), - /** * Optional EIP-712 typed data payload for signing. * Must be JSON-serializable and include required EIP-712 fields. */ - typedData: optional( - type({ - types: record( - string(), - array( - type({ - name: string(), - type: string(), - }), - ), - ), - primaryType: string(), - // Keep values as `any()` here. Using `unknown()` in this record causes - // TS2321/TS2589 (excessive type instantiation depth) in bridge state - // inference during build. - domain: record(string(), any()), - message: record(string(), any()), - }), - ), + typedData: type({ + // Keep values as `any()` here. Using `unknown()` in this record causes + // TS2321/TS2589 (excessive type instantiation depth) in bridge state + // inference during build. + domain: record(string(), any()), + message: record(string(), any()), + }), }); export const QuoteSchema = type({ diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 31ce37eb5a9..99c1d5eca1b 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -18,7 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Bump `@metamask/bridge-controller` from `^67.1.1` to `^67.2.0` ([#8024](https://github.com/MetaMask/core/pull/8024)) - Bump `@metamask/transaction-controller` from `^62.17.1` to `^62.19.0` ([#8005](https://github.com/MetaMask/core/pull/8005), [#8031](https://github.com/MetaMask/core/pull/8031)) -- Make `submitIntent` sign intent typed data internally when signature is not provided, keeping support for externally provided signatures ([#7895](https://github.com/MetaMask/core/pull/7895)). +- **BREAKING:** Make `submitIntent` sign intent typed data internally when signature is not provided, keeping support for externally provided signatures ([#7895](https://github.com/MetaMask/core/pull/7895)). - Move `IntentApiImpl` instantation from `BridgeStatusController` to `IntentManager` ([#8015](https://github.com/MetaMask/core/pull/8015/)) ## [67.0.1] diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index f15a5d0ebfd..9960cf67d7b 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -70,8 +70,6 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { order: { some: 'order' }, settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', typedData: { - types: {}, - primaryType: 'Order', domain: {}, message: {}, }, @@ -552,8 +550,6 @@ describe('BridgeStatusController (intent swaps)', () => { const quoteResponse = minimalIntentQuoteResponse(); quoteResponse.quote.intent.typedData = { - types: {}, - primaryType: 'Order', domain: {}, message: {}, }; @@ -591,22 +587,6 @@ describe('BridgeStatusController (intent swaps)', () => { expect(submitIntentMock.mock.calls[0]?.[0]?.signature).toBe('0xautosigned'); }); - it('submitIntent: throws when signature and typedData are both missing', async () => { - const { controller, accountAddress, submitIntentMock } = setup(); - - const quoteResponse = minimalIntentQuoteResponse(); - quoteResponse.quote.intent.typedData = undefined; - - await expect( - controller.submitIntent({ - quoteResponse, - accountAddress, - }), - ).rejects.toThrow('submitIntent: missing intent typedData'); - - expect(submitIntentMock).not.toHaveBeenCalled(); - }); - it('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { const { controller, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index a57eb295668..b406257dafb 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1604,7 +1604,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata; + quoteResponse: QuoteResponse & QuoteMetadata; accountAddress: string; location?: MetaMetricsSwapsEventSource; abTests?: Record; @@ -1628,13 +1628,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Promise; -}; - /** * The external actions available to the BridgeStatusController. */ From f17ff8ad58d0d2484382f2458447e0553e1ee8b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Mon, 2 Mar 2026 09:33:06 +0100 Subject: [PATCH 21/23] fix: lint issue --- packages/bridge-controller/src/utils/validators.test.ts | 3 +-- .../bridge-status-controller/src/bridge-status-controller.ts | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 48c5e53ca42..8dbbf4da63a 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -357,8 +357,7 @@ describe('validators', () => { is( { ...validIntent, - settlementContract: - '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', + settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', }, IntentSchema, ), diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index b406257dafb..11f5bb7a2f1 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -5,7 +5,6 @@ import type { RequiredEventContextFromClient, TxData, QuoteResponse, - Intent, Trade, } from '@metamask/bridge-controller'; import { @@ -38,7 +37,7 @@ import type { TransactionParams, } from '@metamask/transaction-controller'; import { numberToHex } from '@metamask/utils'; -import type { Hex, Json } from '@metamask/utils'; +import type { Hex } from '@metamask/utils'; import { IntentManager } from './bridge-status-controller.intent'; import { From 43536ea535a65b41c0c252d5a313088bfe07436e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Mon, 2 Mar 2026 10:00:32 +0100 Subject: [PATCH 22/23] fix: add types back to IntentSchema to fix build --- .../src/utils/validators.test.ts | 49 +++++++++++++++---- .../bridge-controller/src/utils/validators.ts | 10 ++++ .../bridge-status-controller.intent.test.ts | 4 ++ 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.test.ts b/packages/bridge-controller/src/utils/validators.test.ts index 8dbbf4da63a..da6ff91c0d2 100644 --- a/packages/bridge-controller/src/utils/validators.test.ts +++ b/packages/bridge-controller/src/utils/validators.test.ts @@ -282,9 +282,10 @@ describe('validators', () => { maxRefreshCount: 5, refreshRate: 30000, support: true, - minimumVersion: '0.0', + minimumVersion: '0.0.0', sse: { enabled: true, + minimumVersion: '0.0', }, }, type: 'sse config - malformed minimum version', @@ -318,12 +319,9 @@ describe('validators', () => { type: 'all evm chains active + an extra field not specified in the schema', expected: true, }, - ])( - 'should return $expected if the response is valid: $type', - ({ response, expected }) => { - expect(validateFeatureFlagsResponse(response)).toBe(expected); - }, - ); + ])('should return $expected for: $type', ({ response, expected }) => { + expect(validateFeatureFlagsResponse(response)).toBe(expected); + }); }); describe('IntentSchema', () => { @@ -343,7 +341,9 @@ describe('validators', () => { protocol: 'cowswap', order: validOrder, typedData: { + types: { Order: [{ name: 'sellToken', type: 'address' }] }, domain: { name: 'GPv2Settlement', chainId: 1 }, + primaryType: 'Order', message: { sellToken: '0x01', buyToken: '0x02' }, }, }; @@ -374,7 +374,7 @@ describe('validators', () => { is( { ...validIntent, - typedData: { message: {} }, + typedData: { types: {}, primaryType: 'Order', message: {} }, }, IntentSchema, ), @@ -386,7 +386,31 @@ describe('validators', () => { is( { ...validIntent, - typedData: { domain: {} }, + typedData: { types: {}, domain: {}, primaryType: 'Order' }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing types', () => { + expect( + is( + { + ...validIntent, + typedData: { domain: {}, primaryType: 'Order', message: {} }, + }, + IntentSchema, + ), + ).toBe(false); + }); + + it('rejects intent with typedData missing primaryType', () => { + expect( + is( + { + ...validIntent, + typedData: { types: {}, domain: {}, message: {} }, }, IntentSchema, ), @@ -408,7 +432,12 @@ describe('validators', () => { is( { ...validIntent, - typedData: { domain: {}, message: {} }, + typedData: { + types: {}, + domain: {}, + primaryType: 'Order', + message: {}, + }, }, IntentSchema, ), diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 457b85d3475..4750862c8e2 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -337,6 +337,16 @@ export const IntentSchema = type({ // Keep values as `any()` here. Using `unknown()` in this record causes // TS2321/TS2589 (excessive type instantiation depth) in bridge state // inference during build. + types: record( + string(), + array( + type({ + name: string(), + type: string(), + }), + ), + ), + primaryType: string(), domain: record(string(), any()), message: record(string(), any()), }), diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 9960cf67d7b..62939ead58d 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -70,7 +70,9 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { order: { some: 'order' }, settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41', typedData: { + types: {}, domain: {}, + primaryType: 'Order', message: {}, }, }, @@ -550,6 +552,8 @@ describe('BridgeStatusController (intent swaps)', () => { const quoteResponse = minimalIntentQuoteResponse(); quoteResponse.quote.intent.typedData = { + types: {}, + primaryType: 'Order', domain: {}, message: {}, }; From cb69a73d86b818c586c574398a21767e3a03d8a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Mon, 2 Mar 2026 16:17:10 +0100 Subject: [PATCH 23/23] fix: failing build --- packages/bridge-status-controller/package.json | 1 + .../bridge-status-controller/src/bridge-status-controller.ts | 3 ++- yarn.lock | 1 + 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 93a3ccc800b..499d9fde8bd 100644 --- a/packages/bridge-status-controller/package.json +++ b/packages/bridge-status-controller/package.json @@ -52,6 +52,7 @@ "@metamask/bridge-controller": "^67.2.0", "@metamask/controller-utils": "^11.19.0", "@metamask/gas-fee-controller": "^26.0.3", + "@metamask/keyring-controller": "^25.1.0", "@metamask/network-controller": "^30.0.0", "@metamask/polling-controller": "^16.0.3", "@metamask/profile-sync-controller": "^27.1.0", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 11f5bb7a2f1..c2076497d8d 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -26,6 +26,7 @@ import { } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; import { toHex } from '@metamask/controller-utils'; +import { SignTypedDataVersion } from '@metamask/keyring-controller'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { TransactionStatus, @@ -1663,7 +1664,7 @@ export class BridgeStatusController extends StaticIntervalPollingController