diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index ac6577aa45e..99b1e997839 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -16,6 +16,7 @@ import { assert, pattern, intersection, + any, } from '@metamask/superstruct'; import { CaipAssetTypeStruct, @@ -332,6 +333,27 @@ export const IntentSchema = type({ * 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(), + domain: any(), + message: any(), + }), + ), }); export const QuoteSchema = type({ 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] 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..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 @@ -535,6 +535,73 @@ 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).toStrictEqual( + 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, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index bedf40bffb6..7026947bd9a 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,23 @@ 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 +1667,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 12d4688e4c5..60469c577c1 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 - | AuthenticationControllerGetBearerTokenAction; + | AuthenticationControllerGetBearerTokenAction + | KeyringControllerSignTypedMessageAction; /** * The external events available to the BridgeStatusController.