Skip to content
6 changes: 0 additions & 6 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -517,9 +517,6 @@
}
},
"packages/bridge-controller/src/types.ts": {
"@typescript-eslint/naming-convention": {
"count": 12
},
"@typescript-eslint/prefer-enum-initializers": {
"count": 3
}
Expand Down Expand Up @@ -604,9 +601,6 @@
"@typescript-eslint/explicit-function-return-type": {
"count": 1
},
"@typescript-eslint/naming-convention": {
"count": 1
},
"id-length": {
"count": 4
}
Expand Down
6 changes: 6 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Add `quoteStreamComplete` state field to `BridgeControllerState`, populated from the `complete` SSE event emitted by the quote stream ([#8306](https://github.com/MetaMask/core/pull/8306))
- Exposes `QuoteStreamCompleteData` type and `validateQuoteStreamComplete` validator
- `quoteStreamComplete` is cleared at the start of each fetch and on `resetState`

### Changed

- Bump `@metamask/assets-controller` from `^3.1.0` to `^3.1.1` ([#8298](https://github.com/MetaMask/core/pull/8298))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ exports[`BridgeController SSE should rethrow error from server 1`] = `
"srcTokenAmount": "1000000000000000000",
"walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294",
},
"quoteStreamComplete": null,
"quotes": [],
"quotesInitialLoadTime": null,
"quotesLoadingStatus": 0,
Expand Down Expand Up @@ -298,6 +299,7 @@ exports[`BridgeController SSE should trigger quote polling if request is valid 1
"srcTokenAmount": "1000000000000000000",
"walletAddress": "0x30E8ccaD5A980BDF30447f8c2C48e70989D9d294",
},
"quoteStreamComplete": null,
"quotes": [],
"quotesInitialLoadTime": null,
"quotesLoadingStatus": 0,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 1`] = `
"srcTokenAmount": "991250000000000000",
"walletAddress": "eip:id/id:id/0x123",
},
"quoteStreamComplete": null,
"quotesInitialLoadTime": 10000,
"quotesLoadingStatus": 1,
"quotesRefreshCount": 1,
Expand Down Expand Up @@ -47,6 +48,7 @@ exports[`BridgeController should handle errors from fetchBridgeQuotes 2`] = `
"srcTokenAmount": "991250000000000000",
"walletAddress": "eip:id/id:id/0x123",
},
"quoteStreamComplete": null,
"quotesInitialLoadTime": 10000,
"quotesLoadingStatus": 1,
"quotesRefreshCount": 1,
Expand Down Expand Up @@ -809,6 +811,7 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po
"srcTokenAmount": "10",
"walletAddress": "0x123",
},
"quoteStreamComplete": null,
"quotes": [],
"quotesInitialLoadTime": null,
"quotesLastFetched": null,
Expand Down Expand Up @@ -840,6 +843,7 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po
"srcTokenAmount": "10",
"walletAddress": "0x123",
},
"quoteStreamComplete": null,
"quotesInitialLoadTime": 10000,
"quotesLoadingStatus": 1,
"quotesRefreshCount": 1,
Expand Down
139 changes: 138 additions & 1 deletion packages/bridge-controller/src/bridge-controller.sse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ import * as balanceUtils from './utils/balance';
import { formatChainIdToDec } from './utils/caip-formatters';
import * as featureFlagUtils from './utils/feature-flags';
import * as fetchUtils from './utils/fetch';
import { TokenFeatureType } from './utils/validators';
import {
TokenFeatureType,
QuoteStreamCompleteReason,
} from './utils/validators';
import { flushPromises } from '../../../tests/helpers';
import mockBridgeQuotesErc20Erc20 from '../tests/mock-quotes-erc20-erc20.json';
import mockBridgeQuotesNativeErc20Eth from '../tests/mock-quotes-native-erc20-eth.json';
Expand All @@ -25,6 +28,7 @@ import {
advanceToNthTimer,
advanceToNthTimerThenFlush,
mockSseEventSource,
mockSseEventSourceWithComplete,
mockSseEventSourceWithMultipleDelays,
mockSseEventSourceWithWarnings,
mockSseServerError,
Expand Down Expand Up @@ -198,6 +202,7 @@ describe('BridgeController SSE', function () {
onQuoteValidationFailure: expect.any(Function),
onValidQuoteReceived: expect.any(Function),
onTokenWarning: expect.any(Function),
onComplete: expect.any(Function),
onClose: expect.any(Function),
},
'13.8.0',
Expand Down Expand Up @@ -338,6 +343,7 @@ describe('BridgeController SSE', function () {
onQuoteValidationFailure: expect.any(Function),
onValidQuoteReceived: expect.any(Function),
onTokenWarning: expect.any(Function),
onComplete: expect.any(Function),
onClose: expect.any(Function),
},
'13.8.0',
Expand Down Expand Up @@ -472,6 +478,7 @@ describe('BridgeController SSE', function () {
onQuoteValidationFailure: expect.any(Function),
onValidQuoteReceived: expect.any(Function),
onTokenWarning: expect.any(Function),
onComplete: expect.any(Function),
onClose: expect.any(Function),
},
'13.8.0',
Expand Down Expand Up @@ -1106,6 +1113,7 @@ describe('BridgeController SSE', function () {
onQuoteValidationFailure: expect.any(Function),
onValidQuoteReceived: expect.any(Function),
onTokenWarning: expect.any(Function),
onComplete: expect.any(Function),
onClose: expect.any(Function),
},
'13.8.0',
Expand Down Expand Up @@ -1304,4 +1312,133 @@ describe('BridgeController SSE', function () {
fakeTokenWarning,
]);
});

it('should populate quoteStreamComplete from complete SSE event', async function () {
const mockComplete = {
quoteCount: 2,
hasQuotes: true,
reason: QuoteStreamCompleteReason.RETRY,
context: { source: 'bridge-api' },
};
mockFetchFn.mockImplementationOnce(async () => {
return mockSseEventSourceWithComplete(
mockBridgeQuotesNativeErc20 as QuoteResponse[],
[],
mockComplete,
);
});

await bridgeController.updateBridgeQuoteRequestParams(
quoteRequest,
metricsContext,
);

expect(bridgeController.state.quoteStreamComplete).toBeNull();

jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();
jest.advanceTimersByTime(5000);
await flushPromises();

expect(bridgeController.state.quoteStreamComplete).toStrictEqual(
mockComplete,
);
expect(bridgeController.state.quotes.length).toBeGreaterThan(0);
});

it('should populate quoteStreamComplete with optional fields omitted', async function () {
const mockComplete = {
quoteCount: 0,
hasQuotes: false,
};
mockFetchFn.mockImplementationOnce(async () => {
return mockSseEventSourceWithComplete([], [], mockComplete);
});

await bridgeController.updateBridgeQuoteRequestParams(
quoteRequest,
metricsContext,
);

jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();
jest.advanceTimersByTime(5000);
await flushPromises();

expect(bridgeController.state.quoteStreamComplete).toStrictEqual(
mockComplete,
);
});

it('should clear quoteStreamComplete on resetState', async function () {
const mockComplete = {
quoteCount: 2,
hasQuotes: true,
};
mockFetchFn.mockImplementationOnce(async () => {
return mockSseEventSourceWithComplete(
mockBridgeQuotesNativeErc20 as QuoteResponse[],
[],
mockComplete,
);
});

await bridgeController.updateBridgeQuoteRequestParams(
quoteRequest,
metricsContext,
);

jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();
jest.advanceTimersByTime(5000);
await flushPromises();

expect(bridgeController.state.quoteStreamComplete).toStrictEqual(
mockComplete,
);

bridgeController.resetState();
expect(bridgeController.state.quoteStreamComplete).toBeNull();
});

it('should clear quoteStreamComplete at the start of each fetch', async function () {
const mockComplete = {
quoteCount: 2,
hasQuotes: true,
};
mockFetchFn.mockImplementation(async () => {
return mockSseEventSourceWithComplete(
mockBridgeQuotesNativeErc20 as QuoteResponse[],
[],
mockComplete,
);
});

await bridgeController.updateBridgeQuoteRequestParams(
quoteRequest,
metricsContext,
);

jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();
jest.advanceTimersByTime(5000);
await flushPromises();

expect(bridgeController.state.quoteStreamComplete).toStrictEqual(
mockComplete,
);

// Trigger a second fetch — quoteStreamComplete should be cleared before the stream completes
jest.advanceTimersByTime(1000);
await advanceToNthTimerThenFlush();

expect(bridgeController.state.quoteStreamComplete).toBeNull();

jest.advanceTimersByTime(5000);
await flushPromises();

expect(bridgeController.state.quoteStreamComplete).toStrictEqual(
mockComplete,
);
});
});
2 changes: 2 additions & 0 deletions packages/bridge-controller/src/bridge-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3469,6 +3469,7 @@ describe('BridgeController', function () {
"quoteRequest": {
"srcTokenAddress": "0x0000000000000000000000000000000000000000",
},
"quoteStreamComplete": null,
"quotes": [],
"quotesInitialLoadTime": null,
"quotesLastFetched": null,
Expand Down Expand Up @@ -3504,6 +3505,7 @@ describe('BridgeController', function () {
"quoteRequest": {
"srcTokenAddress": "0x0000000000000000000000000000000000000000",
},
"quoteStreamComplete": null,
"quotes": [],
"quotesInitialLoadTime": null,
"quotesLastFetched": null,
Expand Down
15 changes: 15 additions & 0 deletions packages/bridge-controller/src/bridge-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ const metadata: StateMetadata<BridgeControllerState> = {
includeInDebugSnapshot: false,
usedInUi: true,
},
quoteStreamComplete: {
includeInStateLogs: true,
persist: false,
includeInDebugSnapshot: false,
usedInUi: true,
},
};

/**
Expand Down Expand Up @@ -614,6 +620,8 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
state.minimumBalanceForRentExemptionInLamports =
DEFAULT_BRIDGE_CONTROLLER_STATE.minimumBalanceForRentExemptionInLamports;
state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings;
state.quoteStreamComplete =
DEFAULT_BRIDGE_CONTROLLER_STATE.quoteStreamComplete;
});
};

Expand Down Expand Up @@ -657,6 +665,8 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
state.quoteRequest = updatedQuoteRequest;
state.quoteFetchError = DEFAULT_BRIDGE_CONTROLLER_STATE.quoteFetchError;
state.tokenWarnings = DEFAULT_BRIDGE_CONTROLLER_STATE.tokenWarnings;
state.quoteStreamComplete =
DEFAULT_BRIDGE_CONTROLLER_STATE.quoteStreamComplete;
state.quotesLastFetched = Date.now();
state.quotesLoadingStatus = RequestStatus.LOADING;
});
Expand Down Expand Up @@ -853,6 +863,11 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
}
});
},
onComplete: (data) => {
this.update((state) => {
state.quoteStreamComplete = data;
});
},
onClose: async () => {
// Wait for all pending appendFeesToQuotes operations to complete
// before setting quotesLoadingStatus to FETCHED
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-controller/src/constants/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export const DEFAULT_BRIDGE_CONTROLLER_STATE: BridgeControllerState = {
assetExchangeRates: {},
minimumBalanceForRentExemptionInLamports: '0',
tokenWarnings: [],
quoteStreamComplete: null,
};

export const METABRIDGE_CHAIN_TO_ADDRESS_MAP: Record<Hex, string> = {
Expand Down
3 changes: 3 additions & 0 deletions packages/bridge-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ export {
BridgeUserAction,
BridgeBackgroundAction,
type TokenFeature,
type QuoteStreamCompleteData,
type BridgeControllerGetStateAction,
type BridgeControllerStateChangeEvent,
} from './types';
Expand All @@ -79,6 +80,8 @@ export {
BridgeAssetSchema,
FeatureId,
TokenFeatureType,
validateQuoteStreamComplete,
QuoteStreamCompleteReason,
} from './utils/validators';

export {
Expand Down
9 changes: 9 additions & 0 deletions packages/bridge-controller/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { AccountsControllerGetAccountByAddressAction } from '@metamask/accounts-controller';
import type { AssetsControllerGetExchangeRatesForBridgeAction } from '@metamask/assets-controller';
import type {
Expand Down Expand Up @@ -42,6 +43,7 @@ import type {
QuoteSchema,
StepSchema,
TokenFeatureSchema,
QuoteStreamCompleteSchema,
TronTradeDataSchema,
TxDataSchema,
} from './utils/validators';
Expand Down Expand Up @@ -325,6 +327,8 @@ export type FeatureFlagsPlatformConfig = Infer<typeof PlatformConfigSchema>;

export type TokenFeature = Infer<typeof TokenFeatureSchema>;

export type QuoteStreamCompleteData = Infer<typeof QuoteStreamCompleteSchema>;

export enum RequestStatus {
LOADING,
FETCHED,
Expand Down Expand Up @@ -384,6 +388,11 @@ export type BridgeControllerState = {
* populated from `token_warning` SSE events.
*/
tokenWarnings: TokenFeature[];
/**
* Metadata about the completed quote stream, populated from the `complete` SSE event.
* Set to null at the start of each fetch and updated when the complete event is received.
*/
quoteStreamComplete: QuoteStreamCompleteData | null;
};

export type BridgeControllerAction<
Expand Down
Loading
Loading