From 1fe19d3c9ffeecb199377f86f93f90a3f88abb02 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Tue, 14 Apr 2026 21:46:12 +0300 Subject: [PATCH 01/12] feat: new --- .../bridge-status-controller/package.json | 2 +- .../src/bridge-status-controller.ts | 125 ++++++++++++++++++ .../bridge-status-controller/src/types.ts | 4 +- 3 files changed, 129 insertions(+), 2 deletions(-) diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index a7ab817201c..6a7b87fab6a 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": "70.0.5", + "version": "70.1.5", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 208cfe7f98e..a7493d6ad08 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -9,6 +9,7 @@ import type { } from '@metamask/bridge-controller'; import { formatChainIdToHex, + getClientHeaders, isNonEvmChainId, StatusTypes, UnifiedSwapBridgeEventName, @@ -23,6 +24,7 @@ import { PollingStatus, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; +import { HttpError } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { TransactionStatus, @@ -136,6 +138,15 @@ export class BridgeStatusController extends StaticIntervalPollingController { #pollingTokensByTxMetaId: Record = {}; + // Tracks txMetaIds whose SUBMITTED status report was rejected with 400 (tx + // data mismatch). Maps txMetaId -> { requestId, srcTxHash } so that the + // final outcome (FINALIZED_SUCCESS / FINALISED_FAILURE) can be reported when + // the transaction confirms or fails. + #pendingTxStatusUpdates: Record< + string, + { requestId: string; srcTxHash: string } + > = {}; + readonly #intentManager: IntentManager; readonly #clientId: BridgeClientId; @@ -224,6 +235,7 @@ export class BridgeStatusController extends StaticIntervalPollingController console.error('FAILWED 1', e)); // Track failed event if (status !== TransactionStatus.rejected) { // Look up history by txMetaId first, then by actionId (for pre-submission failures) @@ -257,6 +269,37 @@ export class BridgeStatusController extends StaticIntervalPollingController + console.error('FAILED HERE 1: ' + e), + ); + } + }, + ); + + // For batch EVM transactions (STX / gasIncluded7702) the tx hash is not + // available when submitTx returns, so we report the submitted status here + // once the TransactionController has broadcast the tx and assigned a hash. + this.messenger.subscribe( + 'TransactionController:transactionSubmitted', + ({ transactionMeta }) => { + const { type, id: txMetaId, hash } = transactionMeta; + if ( + hash && + type && + [TransactionType.bridge, TransactionType.swap].includes(type) + ) { + const historyItem = this.state.txHistory[txMetaId]; + const requestId = historyItem?.quote?.requestId; + if (requestId) { + this.#reportTxSubmitted(requestId, hash, txMetaId).catch((e) => + console.error('FAILED HERE 2: ' + e), + ); + } + } }, ); @@ -843,6 +886,76 @@ export class BridgeStatusController extends StaticIntervalPollingController => { + await this.#fetchFn( + `${this.#config.customBridgeApiBaseUrl}/quote/updateStatus`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ + clientId: this.#clientId, + jwt: await getJwt(this.messenger), + }), + }, + body: JSON.stringify({ + requestId, + newStatus, + srcTxHash, + }), + }, + ); + }; + + /** + * Reports the SUBMITTED status to the Bridge API. If the API rejects with + * HTTP 400 (tx data mismatch), the txMetaId is recorded so that the final + * outcome can be reported via {@link #reportTxFinalised}. + * + * @param requestId - The quote requestId + * @param srcTxHash - The source transaction hash + * @param txMetaId - The transaction meta id used to track finalization + */ + readonly #reportTxSubmitted = async ( + requestId: string, + srcTxHash: string, + txMetaId?: string, + ): Promise => { + try { + await this.#updateQuoteStatus(requestId, srcTxHash, 'SUBMITTED'); + } catch (error) { + if (error instanceof HttpError && error.httpStatus === 400 && txMetaId) { + this.#pendingTxStatusUpdates[txMetaId] = { requestId, srcTxHash }; + } + } + }; + + readonly #reportTxFinalised = async ( + txMetaId: string, + success: boolean, + ): Promise => { + const pending = this.#pendingTxStatusUpdates[txMetaId]; + if (!pending) { + return; + } + delete this.#pendingTxStatusUpdates[txMetaId]; + + const newStatus = success ? 'FINALISED_SUCCESS' : 'FINALISED_FAILURE'; + try { + await this.#updateQuoteStatus( + pending.requestId, + pending.srcTxHash, + newStatus, + ); + } catch { + // Non-fatal: best-effort status reporting + } + }; + /** * ****************************************************** * TX SUBMISSION HANDLING @@ -1171,6 +1284,18 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Wed, 15 Apr 2026 11:41:44 +0300 Subject: [PATCH 02/12] fix: use correct request field --- .../bridge-status-controller/src/bridge-status-controller.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index a7493d6ad08..8ebdee13645 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -903,7 +903,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Wed, 15 Apr 2026 11:44:48 +0300 Subject: [PATCH 03/12] style: fix lint --- .../src/bridge-status-controller.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 8ebdee13645..20302b589d1 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -235,7 +235,7 @@ export class BridgeStatusController extends StaticIntervalPollingController console.error('FAILWED 1', e)); + this.#reportTxFinalised(txMetaId, false).catch((error) => console.error(`FAILED 1: ${error}`)); // Track failed event if (status !== TransactionStatus.rejected) { // Look up history by txMetaId first, then by actionId (for pre-submission failures) @@ -273,8 +273,8 @@ export class BridgeStatusController extends StaticIntervalPollingController - console.error('FAILED HERE 1: ' + e), + this.#reportTxFinalised(txMetaId, true).catch((error) => + console.error(`FAILED 2: ${error}`), ); } }, @@ -295,8 +295,8 @@ export class BridgeStatusController extends StaticIntervalPollingController - console.error('FAILED HERE 2: ' + e), + this.#reportTxSubmitted(requestId, hash, txMetaId).catch((error) => + console.error(`FAILED 3: ${error}`), ); } } From 3afb9f71c924e2920e8cea2f25d46ba7e5295177 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Apr 2026 11:47:05 +0300 Subject: [PATCH 04/12] style: formatting --- .../bridge-status-controller/src/bridge-status-controller.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 20302b589d1..3df240e2c7a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -235,7 +235,9 @@ export class BridgeStatusController extends StaticIntervalPollingController console.error(`FAILED 1: ${error}`)); + this.#reportTxFinalised(txMetaId, false).catch((error) => + console.error(`FAILED 1: ${error}`), + ); // Track failed event if (status !== TransactionStatus.rejected) { // Look up history by txMetaId first, then by actionId (for pre-submission failures) From 2489b21731d13c0db1f2b7056dbc6219707f3662 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Apr 2026 11:49:43 +0300 Subject: [PATCH 05/12] fix: package --- packages/bridge-status-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-status-controller/package.json b/packages/bridge-status-controller/package.json index 6a7b87fab6a..a7ab817201c 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": "70.1.5", + "version": "70.0.5", "description": "Manages bridge-related status fetching functionality for MetaMask", "keywords": [ "Ethereum", From 27e2ff3fd7853c592a70430bd5079ae69ee8fe4c Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Apr 2026 14:04:42 +0300 Subject: [PATCH 06/12] feat: extract logic to service --- .../bridge-controller/src/utils/validators.ts | 1 + .../src/bridge-status-controller.ts | 102 ++----------- .../src/quote-status-update-manager.ts | 136 ++++++++++++++++++ 3 files changed, 152 insertions(+), 87 deletions(-) create mode 100644 packages/bridge-status-controller/src/quote-status-update-manager.ts diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 73603b2ee9b..4ecf246d54d 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -468,6 +468,7 @@ export const QuoteResponseSchema = type({ TronTradeDataSchema, string(), ]), + quoteId: string() }); export const validateQuoteResponse = ( diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 3df240e2c7a..91616086a8e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -9,7 +9,6 @@ import type { } from '@metamask/bridge-controller'; import { formatChainIdToHex, - getClientHeaders, isNonEvmChainId, StatusTypes, UnifiedSwapBridgeEventName, @@ -24,7 +23,6 @@ import { PollingStatus, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; -import { HttpError } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { TransactionStatus, @@ -36,6 +34,7 @@ import { numberToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { IntentManager } from './bridge-status-controller.intent'; +import { QuoteStatusUpdateManager } from './quote-status-update-manager'; import { BRIDGE_PROD_API_BASE_URL, BRIDGE_STATUS_CONTROLLER_NAME, @@ -138,17 +137,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { #pollingTokensByTxMetaId: Record = {}; - // Tracks txMetaIds whose SUBMITTED status report was rejected with 400 (tx - // data mismatch). Maps txMetaId -> { requestId, srcTxHash } so that the - // final outcome (FINALIZED_SUCCESS / FINALISED_FAILURE) can be reported when - // the transaction confirms or fails. - #pendingTxStatusUpdates: Record< - string, - { requestId: string; srcTxHash: string } - > = {}; readonly #intentManager: IntentManager; + readonly #quoteStatusUpdateManager: QuoteStatusUpdateManager; + readonly #clientId: BridgeClientId; readonly #fetchFn: FetchFunction; @@ -204,6 +197,12 @@ export class BridgeStatusController extends StaticIntervalPollingController + this.#quoteStatusUpdateManager.reportFinalised(txMetaId, false).catch((error) => console.error(`FAILED 1: ${error}`), ); // Track failed event @@ -275,7 +274,7 @@ export class BridgeStatusController extends StaticIntervalPollingController + this.#quoteStatusUpdateManager.reportFinalised(txMetaId, true).catch((error) => console.error(`FAILED 2: ${error}`), ); } @@ -297,9 +296,7 @@ export class BridgeStatusController extends StaticIntervalPollingController - console.error(`FAILED 3: ${error}`), - ); + this.#quoteStatusUpdateManager.reportSubmitted(requestId, hash, txMetaId); } } }, @@ -888,76 +885,6 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - await this.#fetchFn( - `${this.#config.customBridgeApiBaseUrl}/quote/updateStatus`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...getClientHeaders({ - clientId: this.#clientId, - jwt: await getJwt(this.messenger), - }), - }, - body: JSON.stringify({ - quoteId: requestId, - newStatus, - srcTxHash, - }), - }, - ); - }; - - /** - * Reports the SUBMITTED status to the Bridge API. If the API rejects with - * HTTP 400 (tx data mismatch), the txMetaId is recorded so that the final - * outcome can be reported via {@link #reportTxFinalised}. - * - * @param requestId - The quote requestId - * @param srcTxHash - The source transaction hash - * @param txMetaId - The transaction meta id used to track finalization - */ - readonly #reportTxSubmitted = async ( - requestId: string, - srcTxHash: string, - txMetaId?: string, - ): Promise => { - try { - await this.#updateQuoteStatus(requestId, srcTxHash, 'SUBMITTED'); - } catch (error) { - if (error instanceof HttpError && error.httpStatus === 400 && txMetaId) { - this.#pendingTxStatusUpdates[txMetaId] = { requestId, srcTxHash }; - } - } - }; - - readonly #reportTxFinalised = async ( - txMetaId: string, - success: boolean, - ): Promise => { - const pending = this.#pendingTxStatusUpdates[txMetaId]; - if (!pending) { - return; - } - delete this.#pendingTxStatusUpdates[txMetaId]; - - const newStatus = success ? 'FINALISED_SUCCESS' : 'FINALISED_FAILURE'; - try { - await this.#updateQuoteStatus( - pending.requestId, - pending.srcTxHash, - newStatus, - ); - } catch { - // Non-fatal: best-effort status reporting - } - }; - /** * ****************************************************** * TX SUBMISSION HANDLING @@ -1286,13 +1213,14 @@ export class BridgeStatusController extends StaticIntervalPollingController(); + + constructor({ + messenger, + fetchFn, + clientId, + apiBaseUrl, + }: { + messenger: BridgeStatusControllerMessenger; + fetchFn: FetchFunction; + clientId: BridgeClientId; + apiBaseUrl: string; + }) { + this.#messenger = messenger; + this.#fetchFn = fetchFn; + this.#clientId = clientId; + this.#apiBaseUrl = apiBaseUrl; + } + + /** + * Fires-and-forgets the SUBMITTED status report to the Bridge API. + * + * - HTTP 409 (tx not yet indexed): retried up to 5 times with a 3-second + * constant delay between attempts via {@link createServicePolicy}. + * - HTTP 400 (tx data mismatch): deferred — the txMetaId is stored so that + * the final outcome can be reported via {@link reportFinalised}. + * - All other errors are silently swallowed (best-effort reporting). + * + * @param quoteId - The quote quoteId + * @param srcTxHash - The source transaction hash + * @param txMetaId - The transaction meta id used to track finalization + */ + reportSubmitted(quoteId: string, srcTxHash: string, txMetaId?: string): void { + const retryPolicy = createServicePolicy({ + maxRetries: 5, + retryFilterPolicy: handleWhen( + (error) => error instanceof HttpError && error.httpStatus === 409, + ), + backoff: new ConstantBackoff(3_000), + }); + + retryPolicy + .execute(() => this.#updateQuoteStatus(quoteId, srcTxHash, QuoteStatusUpdateType.Submitted)) + .catch((error) => { + if (error instanceof HttpError && error.httpStatus === 400 && txMetaId) { + // Tx data mismatch – defer reporting to finalization + this.#pendingTxStatusUpdates.set(txMetaId, { quoteId, srcTxHash }); + } + // All other errors (retries exhausted on 409, 5xx, etc.) are best-effort + }); + } + + /** + * Reports the final outcome (FINALISED_SUCCESS or FINALISED_FAILURE) for any + * transaction whose SUBMITTED call was previously deferred due to HTTP 400. + * If no deferred entry exists for the given txMetaId, this is a no-op. + * + * @param txMetaId - The transaction meta id + * @param success - Whether the transaction succeeded + */ + async reportFinalised(txMetaId: string, success: boolean): Promise { + const pending = this.#pendingTxStatusUpdates.get(txMetaId); + if (!pending) { + return; + } + this.#pendingTxStatusUpdates.delete(txMetaId); + + const newStatus = success ? QuoteStatusUpdateType.FinalizedSuccess : QuoteStatusUpdateType.FinalizedFailure; + try { + await this.#updateQuoteStatus(pending.quoteId, pending.srcTxHash, newStatus); + } catch { + // Non-fatal: best-effort status reporting + } + } + + readonly #updateQuoteStatus = async ( + quoteId: string, + srcTxHash: string, + newStatus: string, + ): Promise => { + await this.#fetchFn(`${this.#apiBaseUrl}/quote/updateStatus`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ + clientId: this.#clientId, + jwt: await getJwt(this.#messenger), + }), + }, + body: JSON.stringify({ + quoteId, + newStatus, + srcTxHash, + }), + }); + }; +} From 17f4900ceb5f9e16552e4a503d6d307fecba5eb8 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Wed, 15 Apr 2026 14:57:19 +0300 Subject: [PATCH 07/12] style: fix --- .../src/bridge-status-controller.ts | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 91616086a8e..ddcedf972dd 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -137,7 +137,6 @@ export class BridgeStatusController extends StaticIntervalPollingController { #pollingTokensByTxMetaId: Record = {}; - readonly #intentManager: IntentManager; readonly #quoteStatusUpdateManager: QuoteStatusUpdateManager; @@ -234,9 +233,9 @@ export class BridgeStatusController extends StaticIntervalPollingController - console.error(`FAILED 1: ${error}`), - ); + this.#quoteStatusUpdateManager + .reportFinalised(txMetaId, false) + .catch((error) => console.error(`FAILED 1: ${error}`)); // Track failed event if (status !== TransactionStatus.rejected) { // Look up history by txMetaId first, then by actionId (for pre-submission failures) @@ -274,9 +273,9 @@ export class BridgeStatusController extends StaticIntervalPollingController - console.error(`FAILED 2: ${error}`), - ); + this.#quoteStatusUpdateManager + .reportFinalised(txMetaId, true) + .catch((error) => console.error(`FAILED 2: ${error}`)); } }, ); @@ -296,7 +295,11 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Wed, 15 Apr 2026 15:20:45 +0300 Subject: [PATCH 08/12] style: fix --- .../bridge-controller/src/utils/validators.ts | 2 +- .../src/bridge-status-controller.ts | 2 +- .../src/quote-status-update-manager.ts | 26 +++++++++++++++---- 3 files changed, 23 insertions(+), 7 deletions(-) diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 4ecf246d54d..23af0709247 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -468,7 +468,7 @@ export const QuoteResponseSchema = type({ TronTradeDataSchema, string(), ]), - quoteId: string() + quoteId: string(), }); export const validateQuoteResponse = ( diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index ddcedf972dd..0533c7d5d30 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -34,7 +34,6 @@ import { numberToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; import { IntentManager } from './bridge-status-controller.intent'; -import { QuoteStatusUpdateManager } from './quote-status-update-manager'; import { BRIDGE_PROD_API_BASE_URL, BRIDGE_STATUS_CONTROLLER_NAME, @@ -42,6 +41,7 @@ import { MAX_ATTEMPTS, REFRESH_INTERVAL_MS, } from './constants'; +import { QuoteStatusUpdateManager } from './quote-status-update-manager'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, diff --git a/packages/bridge-status-controller/src/quote-status-update-manager.ts b/packages/bridge-status-controller/src/quote-status-update-manager.ts index 901c0655017..6c7443afccf 100644 --- a/packages/bridge-status-controller/src/quote-status-update-manager.ts +++ b/packages/bridge-status-controller/src/quote-status-update-manager.ts @@ -13,7 +13,7 @@ import { getJwt } from './utils/authentication'; enum QuoteStatusUpdateType { Submitted = 'SUBMITTED', FinalizedSuccess = 'FINALISED_SUCCESS', - FinalizedFailure = 'FINALISED_FAILURE' + FinalizedFailure = 'FINALISED_FAILURE', } /** @@ -79,9 +79,19 @@ export class QuoteStatusUpdateManager { }); retryPolicy - .execute(() => this.#updateQuoteStatus(quoteId, srcTxHash, QuoteStatusUpdateType.Submitted)) + .execute(() => + this.#updateQuoteStatus( + quoteId, + srcTxHash, + QuoteStatusUpdateType.Submitted, + ), + ) .catch((error) => { - if (error instanceof HttpError && error.httpStatus === 400 && txMetaId) { + if ( + error instanceof HttpError && + error.httpStatus === 400 && + txMetaId + ) { // Tx data mismatch – defer reporting to finalization this.#pendingTxStatusUpdates.set(txMetaId, { quoteId, srcTxHash }); } @@ -104,9 +114,15 @@ export class QuoteStatusUpdateManager { } this.#pendingTxStatusUpdates.delete(txMetaId); - const newStatus = success ? QuoteStatusUpdateType.FinalizedSuccess : QuoteStatusUpdateType.FinalizedFailure; + const newStatus = success + ? QuoteStatusUpdateType.FinalizedSuccess + : QuoteStatusUpdateType.FinalizedFailure; try { - await this.#updateQuoteStatus(pending.quoteId, pending.srcTxHash, newStatus); + await this.#updateQuoteStatus( + pending.quoteId, + pending.srcTxHash, + newStatus, + ); } catch { // Non-fatal: best-effort status reporting } From 66c1e988b707b8aed3ab7036bbb9509aeb8f3900 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Thu, 16 Apr 2026 17:21:32 +0300 Subject: [PATCH 09/12] feat: add persist mechanism and state management --- .../src/bridge-status-controller.ts | 40 ++- .../bridge-status-controller/src/constants.ts | 10 + .../bridge-status-controller/src/index.ts | 1 + .../src/quote-status-update-manager.ts | 338 ++++++++++++++---- .../bridge-status-controller/src/types.ts | 10 + 5 files changed, 323 insertions(+), 76 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 0533c7d5d30..13b6f0f3b7c 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -110,6 +110,12 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + deferredStatusUpdates: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, }; /** The input to start polling for the {@link BridgeStatusController} */ @@ -201,6 +207,12 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.update((draft) => { + draft.deferredStatusUpdates = updates; + }); + }, }); // Register action handlers @@ -233,9 +245,8 @@ export class BridgeStatusController extends StaticIntervalPollingController console.error(`FAILED 1: ${error}`)); + console.log('1 wefwewef - finalized failed', txMetaId) + this.#quoteStatusUpdateManager.reportFinalised(txMetaId, false); // Track failed event if (status !== TransactionStatus.rejected) { // Look up history by txMetaId first, then by actionId (for pre-submission failures) @@ -273,9 +284,8 @@ export class BridgeStatusController extends StaticIntervalPollingController console.error(`FAILED 2: ${error}`)); + console.log('2 wefwewef - finalized success', txMetaId) + this.#quoteStatusUpdateManager.reportFinalised(txMetaId, true); } }, ); @@ -295,6 +305,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.#quoteStatusUpdateManager.destroy(); this.update((state) => { state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; + state.deferredStatusUpdates = + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.deferredStatusUpdates; }); }; @@ -789,6 +803,12 @@ export class BridgeStatusController extends StaticIntervalPollingController; + + readonly #persistDeferredUpdates: ( + updates: Record, + ) => void; + /** - * Tracks txMetaIds whose SUBMITTED report was rejected with HTTP 400 (tx - * data mismatch). Maps txMetaId → { quoteId, srcTxHash } so the final - * outcome can be reported when the transaction confirms or fails. + * Tracks which keys have an in-flight #processSingleEntry call to prevent + * concurrent processing of the same entry. */ - readonly #pendingTxStatusUpdates = new Map< - string, - { quoteId: string; srcTxHash: string } - >(); + readonly #inFlight = new Set(); + + #retryIntervalId: ReturnType | null = null; constructor({ messenger, fetchFn, clientId, apiBaseUrl, + initialDeferredUpdates, + persistDeferredUpdates, }: { messenger: BridgeStatusControllerMessenger; fetchFn: FetchFunction; clientId: BridgeClientId; apiBaseUrl: string; + initialDeferredUpdates?: Record; + persistDeferredUpdates: ( + updates: Record, + ) => void; }) { this.#messenger = messenger; this.#fetchFn = fetchFn; this.#clientId = clientId; this.#apiBaseUrl = apiBaseUrl; + this.#persistDeferredUpdates = persistDeferredUpdates; + + this.#deferredRetryQueue = new Map( + Object.entries(initialDeferredUpdates ?? {}), + ); + + this.#dropExpiredEntries(); + + // If there are items to be processed, start the poller and + // immediately attempt to process all entries (don't wait for + // the first interval tick). + if (this.#deferredRetryQueue.size > 0) { + this.#ensureRetryTimerRunning(); + let delay = 0; + for (const key of this.#deferredRetryQueue.keys()) { + setTimeout(() => this.#processSingleEntry(key), delay); + delay += 125; + } + } } /** - * Fires-and-forgets the SUBMITTED status report to the Bridge API. + * Enqueues a SUBMITTED status report and immediately attempts to send it. * - * - HTTP 409 (tx not yet indexed): retried up to 5 times with a 3-second - * constant delay between attempts via {@link createServicePolicy}. - * - HTTP 400 (tx data mismatch): deferred — the txMetaId is stored so that - * the final outcome can be reported via {@link reportFinalised}. - * - All other errors are silently swallowed (best-effort reporting). - * - * @param quoteId - The quote quoteId + * @param quoteId - The quote id * @param srcTxHash - The source transaction hash - * @param txMetaId - The transaction meta id used to track finalization + * @param txMetaId - Optional transaction meta id for finalization tracking */ reportSubmitted(quoteId: string, srcTxHash: string, txMetaId?: string): void { - const retryPolicy = createServicePolicy({ - maxRetries: 5, - retryFilterPolicy: handleWhen( - (error) => error instanceof HttpError && error.httpStatus === 409, - ), - backoff: new ConstantBackoff(3_000), + const key = this.#enqueue({ + quoteId, + srcTxHash, + txMetaId, + pendingStatuses: [QuoteStatusUpdateType.Submitted], }); - - retryPolicy - .execute(() => - this.#updateQuoteStatus( - quoteId, - srcTxHash, - QuoteStatusUpdateType.Submitted, - ), - ) - .catch((error) => { - if ( - error instanceof HttpError && - error.httpStatus === 400 && - txMetaId - ) { - // Tx data mismatch – defer reporting to finalization - this.#pendingTxStatusUpdates.set(txMetaId, { quoteId, srcTxHash }); - } - // All other errors (retries exhausted on 409, 5xx, etc.) are best-effort - }); + this.#processSingleEntry(key); } /** - * Reports the final outcome (FINALISED_SUCCESS or FINALISED_FAILURE) for any - * transaction whose SUBMITTED call was previously deferred due to HTTP 400. - * If no deferred entry exists for the given txMetaId, this is a no-op. + * Appends the final outcome to the entry's pending statuses queue. + * + * If the entry is not currently in-flight, triggers processing + * immediately. Otherwise the outcome will be picked up once the + * current in-flight call completes and the retry loop continues. * * @param txMetaId - The transaction meta id * @param success - Whether the transaction succeeded */ - async reportFinalised(txMetaId: string, success: boolean): Promise { - const pending = this.#pendingTxStatusUpdates.get(txMetaId); - if (!pending) { + reportFinalised(txMetaId: string, success: boolean): void { + const matchingKey = this.#findKeyByTxMetaId(txMetaId); + if (!matchingKey) { return; } - this.#pendingTxStatusUpdates.delete(txMetaId); - - const newStatus = success - ? QuoteStatusUpdateType.FinalizedSuccess - : QuoteStatusUpdateType.FinalizedFailure; - try { - await this.#updateQuoteStatus( - pending.quoteId, - pending.srcTxHash, - newStatus, + + const entry = this.#deferredRetryQueue.get( + matchingKey, + ) as DeferredStatusUpdateEntry; + + entry.pendingStatuses.push( + success + ? QuoteStatusUpdateType.FinalizedSuccess + : QuoteStatusUpdateType.FinalizedFailure, + ); + this.#persistToState(); + + if (!this.#inFlight.has(matchingKey)) { + this.#processSingleEntry(matchingKey); + } + } + + /** + * Stops the deferred retry timer and clears the in-memory queue. + * Does not persist — the caller is responsible for resetting state. + */ + destroy(): void { + this.#stopRetryTimer(); + this.#deferredRetryQueue.clear(); + this.#inFlight.clear(); + } + + #enqueue( + entry: Omit, + ): string { + const key = `${entry.quoteId}:${entry.srcTxHash}`; + const now = Date.now(); + this.#deferredRetryQueue.set(key, { + ...entry, + createdAt: now, + lastAttemptAt: now, + }); + this.#persistToState(); + this.#ensureRetryTimerRunning(); + return key; + } + + #findKeyByTxMetaId(txMetaId: string): string | undefined { + for (const [key, entry] of this.#deferredRetryQueue) { + if (entry.txMetaId === txMetaId) { + return key; + } + } + return undefined; + } + + #processSingleEntry(key: string): void { + const entry = this.#deferredRetryQueue.get(key); + if (!entry || this.#inFlight.has(key)) { + return; + } + + if (entry.pendingStatuses.length === 0) { + this.#removeEntry(key); + return; + } + + if ( + Date.now() - entry.createdAt > + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS + ) { + console.error( + `QuoteStatusUpdateManager: evicting deferred retry for quote ${entry.quoteId} — exceeded 12-hour retry window`, ); - } catch { - // Non-fatal: best-effort status reporting + this.#removeEntry(key); + return; + } + + this.#inFlight.add(key); + + this.#sendWithRetry( + entry.quoteId, + entry.srcTxHash, + entry.pendingStatuses[0], + ) + .then(() => { + entry.pendingStatuses.shift(); + + if (entry.pendingStatuses.length > 0) { + this.#persistToState(); + this.#inFlight.delete(key); + this.#processSingleEntry(key); + } else { + this.#inFlight.delete(key); + this.#removeEntry(key); + } + return undefined; + }) + .catch((error: unknown) => { + this.#inFlight.delete(key); + + if (error instanceof HttpError && error.httpStatus === 400) { + if (entry.txMetaId) { + entry.pendingStatuses.shift(); + } else { + this.#removeEntry(key); + console.error( + `QuoteStatusUpdateManager: HTTP 400 for quote ${entry.quoteId} with no txMetaId — evicting`, + ); + return; + } + } + + entry.lastAttemptAt = Date.now(); + this.#persistToState(); + }); + } + + async #sendWithRetry( + quoteId: string, + srcTxHash: string, + status: string, + ): Promise { + const policy = createServicePolicy({ + maxRetries: 6, + backoff: new ConstantBackoff(5_000), + }); + await policy.execute(() => + this.#updateQuoteStatus(quoteId, srcTxHash, status), + ); + } + + #removeEntry(key: string): void { + this.#deferredRetryQueue.delete(key); + this.#persistToState(); + if (this.#deferredRetryQueue.size === 0) { + this.#stopRetryTimer(); + } + } + + #ensureRetryTimerRunning(): void { + if (this.#retryIntervalId !== null) { + return; + } + this.#retryIntervalId = setInterval( + () => this.#processDeferredRetries(), + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS, + ); + } + + #stopRetryTimer(): void { + if (this.#retryIntervalId !== null) { + clearInterval(this.#retryIntervalId); + this.#retryIntervalId = null; + } + } + + #processDeferredRetries(): void { + this.#dropExpiredEntries(); + + if (this.#deferredRetryQueue.size === 0) { + this.#stopRetryTimer(); + return; + } + + for (const key of this.#deferredRetryQueue.keys()) { + this.#processSingleEntry(key); + } + } + + #dropExpiredEntries(): void { + const now = Date.now(); + let changed = false; + + for (const [key, entry] of this.#deferredRetryQueue) { + if ( + now - entry.createdAt > + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS + ) { + this.#deferredRetryQueue.delete(key); + console.error( + `QuoteStatusUpdateManager: evicting deferred retry for quote ${entry.quoteId} — exceeded 12-hour retry window`, + ); + changed = true; + } + } + + if (changed) { + this.#persistToState(); + } + } + + /** + * Clones entries before persisting so the controller's state management + * (Immer) does not freeze the in-memory Map objects. + */ + #persistToState(): void { + const cloned: Record = {}; + for (const [key, entry] of this.#deferredRetryQueue) { + cloned[key] = { ...entry, pendingStatuses: [...entry.pendingStatuses] }; } + this.#persistDeferredUpdates(cloned); } readonly #updateQuoteStatus = async ( diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 64ed5d32ee1..3fef9a0c174 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -254,8 +254,18 @@ export type StartPollingForBridgeTxStatusArgsSerialized = Omit< export type SourceChainTxMetaId = string; +export type DeferredStatusUpdateEntry = { + quoteId: string; + srcTxHash: string; + pendingStatuses: string[]; + createdAt: number; + lastAttemptAt: number; + txMetaId?: string; +}; + export type BridgeStatusControllerState = { txHistory: Record; + deferredStatusUpdates: Record; }; // Actions From f26d2bc6eed7f4eb95010a9705299df6439dd847 Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Thu, 16 Apr 2026 18:18:03 +0300 Subject: [PATCH 10/12] style: fix --- .../src/bridge-status-controller.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 13b6f0f3b7c..5a5cb85b639 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -245,7 +245,7 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Thu, 16 Apr 2026 18:23:20 +0300 Subject: [PATCH 11/12] style: fix --- .../src/quote-status-update-manager.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/bridge-status-controller/src/quote-status-update-manager.ts b/packages/bridge-status-controller/src/quote-status-update-manager.ts index 045f50ca1ea..eef16b99d32 100644 --- a/packages/bridge-status-controller/src/quote-status-update-manager.ts +++ b/packages/bridge-status-controller/src/quote-status-update-manager.ts @@ -299,10 +299,7 @@ export class QuoteStatusUpdateManager { let changed = false; for (const [key, entry] of this.#deferredRetryQueue) { - if ( - now - entry.createdAt > - QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS - ) { + if (now - entry.createdAt > QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS) { this.#deferredRetryQueue.delete(key); console.error( `QuoteStatusUpdateManager: evicting deferred retry for quote ${entry.quoteId} — exceeded 12-hour retry window`, From 3ae606ed7a35a62935d0d9c20266f3ebb14d114a Mon Sep 17 00:00:00 2001 From: GeorgeGkas Date: Fri, 17 Apr 2026 11:43:54 +0300 Subject: [PATCH 12/12] fix: edge case --- .../src/bridge-status-controller.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 5a5cb85b639..e287737a2c0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -643,6 +643,10 @@ export class BridgeStatusController extends StaticIntervalPollingController