Skip to content

feat: implement quote status update logic#8462

Open
GeorgeGkas wants to merge 34 commits intomainfrom
swaps-quotes
Open

feat: implement quote status update logic#8462
GeorgeGkas wants to merge 34 commits intomainfrom
swaps-quotes

Conversation

@GeorgeGkas
Copy link
Copy Markdown
Contributor

@GeorgeGkas GeorgeGkas commented Apr 15, 2026

Explanation

Context

The Bridge API requires clients to report the lifecycle of a swap quote — when a transaction is submitted and when it finalizes (success or failure). Previously, BridgeStatusController had no mechanism to send these reports, so the Bridge API had no visibility into quote outcomes.

Additionally, batch EVM transactions (Smart Transactions / 7702-delegated flows) do not have a txHash available at submitTx time, meaning status reporting for those transactions was impossible without subscribing to a later event.

Solution

This PR introduces QuoteStatusUpdateManager inside bridge-status-controller — a resilient, persisted retry queue that reports quote lifecycle events (SUBMITTED, FINALIZED_SUCCESS, FINALIZED_FAILURE) to the Bridge API.

Key behaviours:

  • Immediate retries on transient errors — up to 6 attempts with a short delay before giving up or deferring.
  • Deferred retry queue for network failures — entries are retried every 30 minutes for up to 12 hours, and the queue is persisted to deferredStatusUpdates controller state so it survives service-worker restarts.
  • Non-retryable errors are evicted — errors like SVM_TRADE_DESERIALIZE_FAILED are dropped immediately rather than retried endlessly.
  • Feature-flagged — a new isQuoteStatusUpdateEnabled constructor option (backed by the bridgeQuoteStatusUpdateEnabled remote feature flag in the extension) gates all reporting.
  • Error forwarding — a new onQuoteStatusUpdateError constructor option lets callers (e.g. the extension) forward eviction errors to Sentry.

To support STX / 7702-delegated batch transactions, BridgeStatusController now subscribes to TransactionController:transactionSubmitted and triggers SUBMITTED status reporting when a hash becomes available after the fact.

bridge-controller's QuoteResponseSchema is updated to treat quoteId as a required field, since it is the stable identifier used as the key for the retry queue.

Non-obvious changes

  • BridgeStatusControllerMessenger.AllowedEvents now requires TransactionControllerTransactionSubmittedEvent — consumers must add this event to their messenger AllowedEvents list.
  • A bug in submitIntent was fixed along the way: originalTransactionId is now passed at the top level to #addTxToHistory, so the history item is correctly linked to the synthetic TransactionController entry rather than the orderUid.

References

Checklist

  • I've updated the test suite for new or updated code as appropriate
  • I've updated documentation (JSDoc, Markdown, etc.) for new or updated code as appropriate
  • I've communicated my changes to consumers by updating changelogs for packages I've changed
  • I've introduced breaking changes in this PR and have prepared draft pull requests for clients and consumer packages to resolve them

Note

Medium Risk
Medium risk: introduces new persisted retry queue and new event subscriptions to report quote lifecycle status to the Bridge API, and makes quoteId required in multiple schemas/types, which can break consumers if any integration omits it.

Overview
Adds required quoteId plumbing across bridge quoting and history: QuoteResponseSchema now requires quoteId, test fixtures/mocks/snapshots are updated, and BridgeHistoryItem now includes a required quoteId (breaking).

Introduces resilient quote-status reporting in BridgeStatusController via a new QuoteStatusUpdateManager, persisting a deferredStatusUpdates retry queue and reporting SUBMITTED/finalized success/failure (feature-flagged + error callback). Status reporting is triggered on submission/confirmation/failure/polling-finalization, including a new subscription to TransactionController:transactionSubmitted to handle batch/STX flows where the tx hash is only available after submission.

Fixes batch EVM submission cleanup by deleting the pre-submission (actionId-keyed) history item if batch submission fails, and rekeys batch history to the final txMeta.id while capturing batchId/approvalTxId once available.

Reviewed by Cursor Bugbot for commit c2e1b23. Bugbot is set up for automated code reviews on this repo. Configure here.

@GeorgeGkas GeorgeGkas marked this pull request as ready for review May 4, 2026 14:33
@GeorgeGkas GeorgeGkas requested review from a team as code owners May 4, 2026 14:33
@GeorgeGkas GeorgeGkas changed the title Swaps quotes feat: implement quote status update logic May 4, 2026
Comment thread packages/bridge-status-controller/src/bridge-status-controller.ts
Comment thread packages/bridge-status-controller/src/bridge-status-controller.ts
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 2505ac0. Configure here.

// Report finalized success for swap/bridge transactions
if (txMeta.type && isCrossChainTx(txMeta.type)) {
this.#quoteStatusUpdateManager.reportFinalised(txMeta.id, true);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Premature finalized-success reported for bridge transactions

High Severity

In #onTransactionConfirmed, reportFinalised(txMeta.id, true) is called for all cross-chain tx types (both swaps and bridges) when the source chain transaction confirms. For same-chain swaps this is correct — confirmation means the swap is done. For bridge transactions, however, source chain confirmation only means the first leg succeeded; the destination chain transfer is still pending. The #fetchBridgeTxStatus polling handler later calls reportFinalised again with the actual bridge outcome. This means a bridge that ultimately fails on the destination chain will first send FINALIZED_SUCCESS (premature) and then FINALIZED_FAILURE, providing contradictory lifecycle data to the Bridge API.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 2505ac0. Configure here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is expected, we care only about reporting the success/failure of the the source tx.

Comment thread packages/bridge-controller/CHANGELOG.md
@GeorgeGkas GeorgeGkas requested a review from infiniteflower May 4, 2026 18:10
this.state.txHistory[txMetaId] ??
(actionId ? this.state.txHistory[actionId] : undefined);
if (historyItem?.quoteId) {
this.#quoteStatusUpdateManager.reportSubmitted(
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just wondering why this is called twice, once here at transactionSubmitted and once at transactionStatusUpdated (submitted status) above? Aren't those 2 the same and would there be duplicated reports?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@infiniteflower they are indeed similar. There was a change recently introduced on the transaction controller where they added a universal event TransactionController:transactionStatusUpdated. I originally made the implementation using the separate submitted event listener and later they introduced the updated event. I kept both for completeness, note there are not issues with duplicate calls, I already tested that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@infiniteflower I gave it another look and we indeed require both. TransactionController:transactionStatusSubmitted is called immediately once we broadcast the tx while the update event is triggered on later time. If we omit the submitted event listener, there is an edge case where the quote status manager won't be able to create a record if user closes the app immediately after submitting a swap (or loose internet connection). We'll never get back the tx status in that case.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the #onTransactionStatusChange handler in TransactionController (line 3337) publishes transactionStatusUpdated right after submitted, so I think this does duplicate the event handling

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We use the generic transaction statusUpdated event instead of specific ones like transactionSubmitted because the initial hash generated during submission can be replaced before it's confirmed on chain

Comment thread packages/bridge-status-controller/src/types.ts Outdated
Comment on lines +12 to +13
- Add required `quoteId: string` to `QuoteResponseSchema`; quote responses without a `quoteId` will now fail validation ([#8462](https://github.com/MetaMask/core/pull/8462))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please mark this as a breaking change. Client e2e tests will need to be updated

@GeorgeGkas GeorgeGkas requested a review from infiniteflower May 4, 2026 19:51

// Finalize the quote status update as a failure since we can no longer
// determine the outcome via polling.
this.#quoteStatusUpdateManager.reportFinalised(bridgeTxMetaId, false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The controller restarts polling for these transactions next time the wallet restarts so it's too soon to report finalization here. This will also result in duplicate finalization status updates

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The #handleOldHistoryItem handler deletes the history item to permanently stop polling so the report should happen there

isCrossChainTx(txMeta.type) &&
!isApprovalTxMeta
) {
if (historyItem?.quoteId && txMeta.hash) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple places in the code where we assume quoteId is defined, but it won't be for older txHistory items. Can you make it optional in the BridgeHistoryItem type to ensure this undefined check is always done?


// Report finalized success for swap/bridge transactions
if (txMeta.type && isCrossChainTx(txMeta.type)) {
this.#quoteStatusUpdateManager.reportFinalised(txMeta.id, true);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for bridge transactions, do we only care about the src transaction? or should finalized only be reported when both src and dest are confirmed?

Comment on lines +599 to +600
return (await res.json()) as QuoteStatusUpdateResponse;
};
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please add a validator and infer the type for QuoteStatusUpdateResponse rather than casting the response. Validating ensures that the data matches the shape we expect. In the worst case the app or background worker may crash when we attempt to use unvalidated objects

addTransactionBatchFn,
config,
traceFn,
onQuoteStatusUpdateError,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this logic going to be different across clients?

config,
traceFn,
onQuoteStatusUpdateError,
isQuoteStatusUpdateEnabled,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is only based on a client feature flag, I'd read the flag directly in the controller/update manager

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That was my initial thought as well, but I wanted to keep the controller as stateless as possible, which is why I prefer to pass callbacks and let clients handle initialization. While directly reading the flag might seem appropriate for now, it introduces tight coupling between our service and an external dependency (the LD flags), which complicates testing.

My main concern, however, is that this won’t be the final requirement for the update manager. Since Ethan is planning to add more logic after v1, and we don’t know the exact changes yet, I wanted to keep the interface generic. That way, when we eventually update the implementation logic, the public API can remain stable, making future documentation simpler and more consistent.


// Report finalized failure for swap/bridge transactions
if (txMeta.type && isCrossChainTx(txMeta.type)) {
this.#quoteStatusUpdateManager.reportFinalised(txMeta.id, false);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Have you tested this with a HW wallet rejection? If you submit the trade, but reject the tx on a HW wallet, does the submitted event fire? If it doesn't then this should happen after the rejected check below

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, will have to do it.

method: 'POST',
headers: {
'Content-Type': 'application/json',
...getClientHeaders({
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the clientVersion too

Comment on lines +460 to +470
if (
type === QuoteStatusUpdateErrorType.InvalidStatusTransaction ||
type === QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch
) {
const finalizationStatus = response.currentStatus;
entry.pendingStatuses = [finalizationStatus];
this.#persistToState();
this.#inFlight.delete(key);
await this.#attemptFinalizationWithRetries(key, entry);
return;
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why post the status update again? If this happens, doesn't it mean the backend already has the fimnalizationStatus. So all we need to do is remove this from the deferred statuses

Comment on lines +142 to +144
for (const key of this.#deferredRetryQueue.keys()) {
setTimeout(() => this.#processSingleEntry(key), delay);
delay += 125;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ensureRetryTimerRunning also causes these entries to be processed. Why do both need to be started around the same time?

@GeorgeGkas
Copy link
Copy Markdown
Contributor Author

From @micaelae :

I think this means the controller will need to publish the “submitted” event for intents after the submitOrder call as well

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants