diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index 73603b2ee9b..23af0709247 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 208cfe7f98e..e287737a2c0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -41,6 +41,7 @@ import { MAX_ATTEMPTS, REFRESH_INTERVAL_MS, } from './constants'; +import { QuoteStatusUpdateManager } from './quote-status-update-manager'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, @@ -109,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} */ @@ -138,6 +145,8 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.update((draft) => { + draft.deferredStatusUpdates = updates; + }); + }, + }); // Register action handlers this.messenger.registerMethodActionHandlers( @@ -224,6 +245,8 @@ export class BridgeStatusController extends StaticIntervalPollingController { + 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) { + console.log('3 wefwewef - submitted', requestId, hash, txMetaId); + this.#quoteStatusUpdateManager.reportSubmitted( + requestId, + hash, + txMetaId, + ); + } + } }, ); @@ -296,8 +352,11 @@ 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; }); }; @@ -584,6 +643,10 @@ export class BridgeStatusController extends StaticIntervalPollingController; + + readonly #persistDeferredUpdates: ( + updates: Record, + ) => void; + + /** + * Tracks which keys have an in-flight #processSingleEntry call to prevent + * concurrent processing of the same entry. + */ + 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; + } + } + } + + /** + * Enqueues a SUBMITTED status report and immediately attempts to send it. + * + * @param quoteId - The quote id + * @param srcTxHash - The source transaction hash + * @param txMetaId - Optional transaction meta id for finalization tracking + */ + reportSubmitted(quoteId: string, srcTxHash: string, txMetaId?: string): void { + const key = this.#enqueue({ + quoteId, + srcTxHash, + txMetaId, + pendingStatuses: [QuoteStatusUpdateType.Submitted], + }); + this.#processSingleEntry(key); + } + + /** + * 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 + */ + reportFinalised(txMetaId: string, success: boolean): void { + const matchingKey = this.#findKeyByTxMetaId(txMetaId); + if (!matchingKey) { + return; + } + + 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`, + ); + 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 ( + 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, + }), + }); + }; +} diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 20ba3f21619..3fef9a0c174 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -32,6 +32,7 @@ import type { TransactionControllerTransactionConfirmedEvent, TransactionControllerTransactionFailedEvent, TransactionControllerUpdateTransactionAction, + TransactionControllerTransactionSubmittedEvent, TransactionMeta, } from '@metamask/transaction-controller'; import type { CaipAssetType } from '@metamask/utils'; @@ -253,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 @@ -310,7 +321,8 @@ type AllowedActions = */ type AllowedEvents = | TransactionControllerTransactionFailedEvent - | TransactionControllerTransactionConfirmedEvent; + | TransactionControllerTransactionConfirmedEvent + | TransactionControllerTransactionSubmittedEvent; /** * The messenger for the BridgeStatusController.