Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
4091b04
feat: allow intent typed data in bridge schema
oscarwroche Feb 10, 2026
32df2d0
feat: validate intent typedData as JSON
oscarwroche Feb 10, 2026
f140729
feat: enforce typedData shape in intent schema
oscarwroche Feb 10, 2026
f930d4f
fix: fix type depth error
oscarwroche Feb 10, 2026
868600e
Merge branch 'main' into intent-backend-api-change
oscarwroche Feb 17, 2026
d705e64
Merge remote-tracking branch 'origin/main' into intent-backend-api-ch…
oscarwroche Feb 20, 2026
53ab950
Add bridge-controller changelog entry for intent typedData
oscarwroche Feb 20, 2026
407b978
feat(bridge-status): sign intent typedData in core submitIntent
oscarwroche Feb 24, 2026
d9a5ba2
Merge main into intent-backend-api-change
oscarwroche Feb 24, 2026
d780875
test(bridge-status): cover in-core intent signing path
oscarwroche Feb 24, 2026
00813c7
fix bridge status controller lint issues
oscarwroche Feb 24, 2026
a5f6b74
update bridge-status-controller changelog
oscarwroche Feb 25, 2026
f7f26ad
Merge branch 'main' into intent-backend-api-change
oscarwroche Feb 25, 2026
9dfbdd2
update bridge-controller changelog for intent typedData
oscarwroche Feb 25, 2026
ab9d5f5
Merge branch 'main' into intent-backend-api-change
oscarwroche Feb 25, 2026
7e2f3d9
link bridge changelog entries to PR
oscarwroche Feb 25, 2026
23334d6
Merge branch 'main' into intent-backend-api-change
oscarwroche Feb 25, 2026
5593b79
Merge origin/feat/intent-v2 into intent-backend-api-change
oscarwroche Feb 26, 2026
60faf46
Fix lint naming convention for ab_tests extraction
oscarwroche Feb 26, 2026
14720e6
Remove precomputed signature from bridge intent submission
oscarwroche Feb 27, 2026
2ae849e
Tighten typedData domain/message validation to unknown records
oscarwroche Feb 27, 2026
d763977
Fix intent tests for required typedData signing path
oscarwroche Feb 27, 2026
12a3874
fix(bridge-controller): avoid typedData domain/message type recursion
oscarwroche Feb 27, 2026
3b057ea
docs(bridge-controller): explain typedData any fallback
oscarwroche Feb 27, 2026
5e001a0
refactor(bridge-controller): use record(string(), any()) for typedData
oscarwroche Feb 27, 2026
2039520
fix(bridge-status): validate intent typedData before approval
oscarwroche Feb 27, 2026
1ac24ad
fix: review comments and adjust related tests
Akaryatrh Mar 1, 2026
f17ff8a
fix: lint issue
Akaryatrh Mar 2, 2026
43536ea
fix: add types back to IntentSchema to fix build
Akaryatrh Mar 2, 2026
cb69a73
fix: failing build
Akaryatrh Mar 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Bump `@metamask/remote-feature-flag-controller` from `^4.0.0` to `^4.1.0` ([#8041](https://github.com/MetaMask/core/pull/8041))
- 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 ([#7895](https://github.com/MetaMask/core/pull/7895)).

## [67.2.0]

Expand Down
135 changes: 128 additions & 7 deletions packages/bridge-controller/src/utils/validators.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { validateFeatureFlagsResponse } from './validators';
import { is } from '@metamask/superstruct';

import { validateFeatureFlagsResponse, IntentSchema } from './validators';

describe('validators', () => {
describe('validateFeatureFlagsResponse', () => {
Expand Down Expand Up @@ -280,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',
Expand Down Expand Up @@ -316,11 +319,129 @@ 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', () => {
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: {
types: { Order: [{ name: 'sellToken', type: 'address' }] },
domain: { name: 'GPv2Settlement', chainId: 1 },
primaryType: 'Order',
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: { types: {}, primaryType: 'Order', message: {} },
},
IntentSchema,
),
).toBe(false);
});

it('rejects intent with typedData missing message', () => {
expect(
is(
{
...validIntent,
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,
),
).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: {
types: {},
domain: {},
primaryType: 'Order',
message: {},
},
},
IntentSchema,
),
).toBe(true);
});
});
});
22 changes: 20 additions & 2 deletions packages/bridge-controller/src/utils/validators.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { isValidHexAddress } from '@metamask/controller-utils';
import type { Infer } from '@metamask/superstruct';
import {
any,
string,
boolean,
number,
Expand Down Expand Up @@ -329,9 +330,26 @@ export const IntentSchema = type({
settlementContract: optional(HexAddressSchema),

/**
* Optional relayer address responsible for order submission.
* Optional EIP-712 typed data payload for signing.
* Must be JSON-serializable and include required EIP-712 fields.
*/
relayer: optional(HexAddressSchema),
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.
types: record(
string(),
array(
type({
name: string(),
type: string(),
}),
),
),
primaryType: string(),
domain: record(string(), any()),
message: record(string(), any()),
}),
});

export const QuoteSchema = type({
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-status-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +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))
- **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]
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-status-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,12 @@ const minimalIntentQuoteResponse = (overrides?: Partial<any>): any => {
protocol: 'cowswap',
order: { some: 'order' },
settlementContract: '0x9008D19f58AAbd9eD0D60971565AA8510560ab41',
typedData: {
types: {},
domain: {},
primaryType: 'Order',
message: {},
},
},
},
sentAmount: { amount: '1', usd: '1' },
Expand Down Expand Up @@ -169,6 +175,8 @@ const createMessengerHarness = (
return undefined;
case 'GasFeeController:getState':
return { gasFeeEstimates: {} };
case 'KeyringController:signTypedMessage':
return '0xtest-signature';
default:
return undefined;
}
Expand Down Expand Up @@ -401,7 +409,6 @@ describe('BridgeStatusController (intent swaps)', () => {

const promise = controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});
expect(await promise.catch((error: any) => error)).toStrictEqual(
Expand Down Expand Up @@ -448,7 +455,6 @@ describe('BridgeStatusController (intent swaps)', () => {
await expect(
controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
}),
).resolves.toBeDefined();
Expand Down Expand Up @@ -482,7 +488,6 @@ describe('BridgeStatusController (intent swaps)', () => {

const promise = controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});
expect(await promise.catch((error: any) => error)).toStrictEqual(
Expand Down Expand Up @@ -522,7 +527,6 @@ describe('BridgeStatusController (intent swaps)', () => {

const result = await controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});

Expand All @@ -535,6 +539,58 @@ describe('BridgeStatusController (intent swaps)', () => {
consoleSpy.mockRestore();
});

it('submitIntent: signs typedData', 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('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => {
const {
controller,
Expand All @@ -557,7 +613,6 @@ describe('BridgeStatusController (intent swaps)', () => {

await controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});

Expand Down Expand Up @@ -602,7 +657,6 @@ describe('BridgeStatusController (intent swaps)', () => {

await controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});

Expand Down Expand Up @@ -649,7 +703,6 @@ describe('BridgeStatusController (intent swaps)', () => {

await controller.submitIntent({
quoteResponse,
signature: '0xsig',
accountAddress,
});

Expand Down
Loading