From f64c031bb486a6d935878401b567bc9eedaf0dc5 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Tue, 25 Aug 2026 10:07:58 +0200 Subject: [PATCH 1/5] feat(bridge): emit SwapBridge failure_phase, error_code, and hash presence Wire Quotes Error, submit Failed, EVM Failed, poll Failed, Submitted, and Completed to the classifiers from #9947. Mixpanel can now split quote vs broadcast vs source vs dest vs poll without parsing error_message. --- packages/bridge-controller/CHANGELOG.md | 1 + .../bridge-controller.sse.test.ts.snap | 4 + .../bridge-controller.test.ts.snap | 10 ++ .../src/bridge-controller.ts | 42 ++++--- .../bridge-status-controller/CHANGELOG.md | 1 + .../bridge-status-controller.test.ts.snap | 108 ++++++++++++++++++ ...ridge-status-controller.batch-sell.test.ts | 14 +++ .../src/bridge-status-controller.test.ts | 33 ++++++ .../src/bridge-status-controller.ts | 34 ++++++ .../src/utils/metrics.test.ts | 22 ++++ .../src/utils/metrics.ts | 7 ++ 11 files changed, 263 insertions(+), 13 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 4bae387fa09..484dbdd0cb2 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional `failure_phase` and `error_code` on Quotes Error and Failed event context types - Optional `source_hash_present` and `destination_hash_present` on Failed, Submitted, and Completed event context types - Submit and status classifiers live in `@metamask/bridge-status-controller` +- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error and Failed events ## [80.0.0] diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap index 7fcfe0b4439..f7d7cf47f39 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.sse.test.ts.snap @@ -159,7 +159,9 @@ exports[`BridgeController SSE should reset quotes list if quote refresh fails 2` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, + "error_code": "non_error_rejection", "error_message": "Network error", + "failure_phase": "quote", "feature_id": "unified_swap_bridge", "has_sufficient_funds": true, "is_hardware_wallet": false, @@ -293,7 +295,9 @@ exports[`BridgeController SSE should rethrow error from server 3`] = ` "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, + "error_code": "quote_fetch_failed", "error_message": "Bridge-api error: timeout from server", + "failure_phase": "quote", "feature_id": "unified_swap_bridge", "has_sufficient_funds": true, "is_hardware_wallet": false, diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index 39ba70cc308..9d3cdb4c943 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -121,8 +121,11 @@ exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller c "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, + "destination_hash_present": false, "destination_transaction": "PENDING", + "error_code": "unknown", "error_message": "error_message", + "failure_phase": "unknown", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -137,6 +140,7 @@ exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller c "quotes_list": [], "security_warnings": [], "slippage_limit": undefined, + "source_hash_present": false, "source_transaction": "PENDING", "stx_enabled": false, "swap_type": "crosschain", @@ -163,7 +167,10 @@ exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller c "chain_id_destination": "eip155:1", "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "custom_slippage": true, + "destination_hash_present": false, + "error_code": "unknown", "error_message": "Failed to submit tx", + "failure_phase": "unknown", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -180,6 +187,7 @@ exports[`BridgeController trackUnifiedSwapBridgeEvent bridge-status-controller c "lifi_mayanMCTP", ], "slippage_limit": 0.5, + "source_hash_present": false, "stx_enabled": false, "swap_type": "crosschain", "token_address_destination": "eip155:1/erc20:0x1234", @@ -1076,7 +1084,9 @@ exports[`BridgeController updateBridgeQuoteRequestParams should trigger quote po "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "chain_id_source": "eip155:1", "custom_slippage": true, + "error_code": "quote_fetch_failed", "error_message": "Network error", + "failure_phase": "quote", "feature_id": "unified_swap_bridge", "has_sufficient_funds": true, "is_hardware_wallet": false, diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index 62f27167288..3004766b7d4 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -61,14 +61,17 @@ import { import { AbortReason, BatchSellMetricsEventName, + FailurePhase, MetaMetricsSwapsEventSource, MetricsActionType, + SwapBridgeErrorCode, UnifiedSwapBridgeEventName, } from './utils/metrics/constants.js'; import type { BridgeControllerMetricsEventName, BridgeControllerMetricsLocation, } from './utils/metrics/constants.js'; +import { getQuoteFetchErrorCode } from './utils/metrics/failure-telemetry.js'; import { formatProviderLabel, getAccountHardwareType, @@ -977,10 +980,11 @@ export class BridgeController extends StaticIntervalPollingController { chain_id_destination: 'eip155:10', chain_id_source: 'eip155:10', custom_slippage: false, + destination_hash_present: false, feature_id: FeatureId.BATCH_SELL, gas_included: gasIncluded, gas_included_7702: gasIncluded7702, @@ -326,6 +327,7 @@ describe('BridgeStatusController', () => { price_impact: 0, provider: 'socket_across', quoted_time_minutes: 1, + source_hash_present: false, stx_enabled: stxEnabled, swap_type: 'single_chain', token_address_destination: @@ -550,6 +552,7 @@ describe('BridgeStatusController', () => { chain_id_destination: 'eip155:10', chain_id_source: 'eip155:10', custom_slippage: true, + destination_hash_present: false, destination_transaction: 'PENDING', gas_included: gasIncluded, gas_included_7702: gasIncluded7702, @@ -562,6 +565,7 @@ describe('BridgeStatusController', () => { quoted_vs_used_gas_ratio: 0, security_warnings: [], slippage_limit: 0, + source_hash_present: true, source_transaction: 'COMPLETE', stx_enabled: stxEnabled, swap_type: 'single_chain', @@ -628,8 +632,11 @@ describe('BridgeStatusController', () => { chain_id_destination: 'eip155:10', chain_id_source: 'eip155:10', custom_slippage: true, + destination_hash_present: false, destination_transaction: 'FAILED', + error_code: 'missing_error_object', error_message: 'Transaction failed', + failure_phase: 'source_execution', feature_id: FeatureId.BATCH_SELL, gas_included: gasIncluded, gas_included_7702: gasIncluded7702, @@ -644,6 +651,7 @@ describe('BridgeStatusController', () => { quoted_vs_used_gas_ratio: 0, security_warnings: [], slippage_limit: 0, + source_hash_present: true, source_transaction: 'COMPLETE', stx_enabled: stxEnabled, swap_type: 'single_chain', @@ -764,6 +772,7 @@ describe('BridgeStatusController', () => { chain_id_destination: 'eip155:10', chain_id_source: 'eip155:10', custom_slippage: false, + destination_hash_present: false, feature_id: FeatureId.BATCH_SELL, gas_included: gasIncluded, gas_included_7702: gasIncluded7702, @@ -772,6 +781,7 @@ describe('BridgeStatusController', () => { price_impact: 0, provider: 'socket_across', quoted_time_minutes: 1, + source_hash_present: false, stx_enabled: stxEnabled, swap_type: 'single_chain', token_address_destination: @@ -805,8 +815,11 @@ describe('BridgeStatusController', () => { chain_id_destination: 'eip155:10', chain_id_source: 'eip155:10', custom_slippage: false, + destination_hash_present: false, + error_code: 'unknown', error_message: 'Failed to add BatchSell trade to history: txMeta not found', + failure_phase: 'broadcast', feature_id: FeatureId.BATCH_SELL, gas_included: false, gas_included_7702: true, @@ -815,6 +828,7 @@ describe('BridgeStatusController', () => { price_impact: 0, provider: 'socket_across', quoted_time_minutes: 1, + source_hash_present: false, stx_enabled: false, swap_type: 'single_chain', token_address_destination: diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 63d8c217eb9..0c8d034bd10 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -1778,6 +1778,19 @@ describe('BridgeStatusController', () => { // Assertions expect(fetchBridgeTxStatusSpy).toHaveBeenCalledTimes(1); expect(messengerCallSpy.mock.calls).toMatchSnapshot(); + const failedCall = messengerCallSpy.mock.calls.find( + ([action, eventName]) => + action === 'BridgeController:trackUnifiedSwapBridgeEvent' && + eventName === UnifiedSwapBridgeEventName.Failed, + ); + expect(failedCall?.[2]).toStrictEqual( + expect.objectContaining({ + failure_phase: 'source_execution', + error_code: 'status_failed_without_reason', + source_hash_present: true, + destination_hash_present: false, + }), + ); expect(messengerPublishSpy).not.toHaveBeenCalledWith( 'BridgeStatusController:destinationTransactionCompleted', ); @@ -5004,7 +5017,10 @@ describe('BridgeStatusController', () => { "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": false, + "destination_hash_present": false, + "error_code": "unknown", "error_message": "Failed to submit cross-chain swap batch transaction: unknown account in trade data", + "failure_phase": "broadcast", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -5013,6 +5029,7 @@ describe('BridgeStatusController', () => { "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0, + "source_hash_present": false, "stx_enabled": true, "swap_type": "single_chain", "token_address_destination": "eip155:10/slip44:60", @@ -5092,7 +5109,10 @@ describe('BridgeStatusController', () => { "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": false, + "destination_hash_present": false, + "error_code": "unknown", "error_message": "Failed to update cross-chain swap transaction batch: tradeMeta not found", + "failure_phase": "broadcast", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -5101,6 +5121,7 @@ describe('BridgeStatusController', () => { "price_impact": 0, "provider": "lifi_across", "quoted_time_minutes": 0, + "source_hash_present": false, "stx_enabled": true, "swap_type": "single_chain", "token_address_destination": "eip155:10/slip44:60", @@ -5684,7 +5705,10 @@ describe('BridgeStatusController', () => { "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": false, + "destination_hash_present": false, + "error_code": "unknown", "error_message": "Transaction failed. tx-error", + "failure_phase": "broadcast", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -5696,6 +5720,7 @@ describe('BridgeStatusController', () => { "quoted_time_minutes": 0, "quoted_vs_used_gas_ratio": 0, "security_warnings": [], + "source_hash_present": false, "source_transaction": "FAILED", "stx_enabled": false, "swap_type": "crosschain", @@ -5871,8 +5896,11 @@ describe('BridgeStatusController', () => { "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": true, + "destination_hash_present": false, "destination_transaction": "FAILED", + "error_code": "unknown", "error_message": "Transaction failed. tx-error", + "failure_phase": "broadcast", "feature_id": "quick_buy_follow_trading", "gas_included": false, "gas_included_7702": false, @@ -5885,6 +5913,7 @@ describe('BridgeStatusController', () => { "quoted_vs_used_gas_ratio": 0, "security_warnings": [], "slippage_limit": 0, + "source_hash_present": true, "source_transaction": "COMPLETE", "stx_enabled": false, "swap_type": "single_chain", @@ -5949,8 +5978,11 @@ describe('BridgeStatusController', () => { "chain_id_destination": "eip155:42161", "chain_id_source": "eip155:42161", "custom_slippage": true, + "destination_hash_present": false, "destination_transaction": "FAILED", + "error_code": "unknown", "error_message": "Transaction failed. tx-error", + "failure_phase": "broadcast", "feature_id": "quick_buy_explore", "gas_included": false, "gas_included_7702": false, @@ -5963,6 +5995,7 @@ describe('BridgeStatusController', () => { "quoted_vs_used_gas_ratio": 0, "security_warnings": [], "slippage_limit": 0, + "source_hash_present": true, "source_transaction": "COMPLETE", "stx_enabled": false, "swap_type": "single_chain", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 02e7b754b96..1ae5431e442 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -11,6 +11,7 @@ import { toQuoteMetadataV1, toQuoteResponseV1, isQuoteResponseV2, + SwapBridgeErrorCode, } from '@metamask/bridge-controller'; import type { QuoteMetadataMigrationPhase } from '@metamask/bridge-controller'; import { @@ -96,6 +97,9 @@ import { getEVMTxPropertiesFromTransactionMeta, getTxStatusesFromHistory, getPreConfirmationPropertiesFromQuote, + getHashPresenceProperties, + getSubmitFailureTelemetry, + getStatusFailureTelemetry, } from './utils/metrics.js'; import { getSelectedChainId } from './utils/network.js'; import { @@ -1532,6 +1536,8 @@ export class BridgeStatusController extends StaticIntervalPollingController tx.id === approvalTxId, ); + const failedProperties = eventProperties as + | RequiredEventContextFromClient[typeof UnifiedSwapBridgeEventName.Failed] + | undefined; + + const historyHashPresence = getHashPresenceProperties( + historyItem.status.srcChain.txHash, + historyItem.status.destChain?.txHash, + ); + const requiredEventProperties = { ...baseProperties, ...requestParamProperties, @@ -1832,6 +1848,7 @@ export class BridgeStatusController extends StaticIntervalPollingController { usd_actual_return: 0, usd_actual_gas: 0, action_type: MetricsActionType.SWAPBRIDGE_V1, + source_hash_present: false, + destination_hash_present: false, + failure_phase: FailurePhase.Broadcast, + error_code: SwapBridgeErrorCode.MissingErrorObject, }); }); @@ -1116,6 +1120,24 @@ describe('metrics utils', () => { ); expect(result.error_message).toBe('Transaction failed. Error message'); expect(result.source_transaction).toBe('FAILED'); + expect(result.failure_phase).toBe(FailurePhase.Broadcast); + expect(result.error_code).toBe(SwapBridgeErrorCode.Unknown); + }); + + it('sets source_execution when the failed tx has a hash', () => { + const failedWithHash: TransactionMeta = { + ...mockTransactionMeta, + status: TransactionStatus.failed, + hash: '0xabc', + error: { + message: 'reverted', + name: 'Error', + } as TransactionError, + }; + const result = getEVMTxPropertiesFromTransactionMeta(failedWithHash); + expect(result.source_hash_present).toBe(true); + expect(result.failure_phase).toBe(FailurePhase.SourceExecution); + expect(result.error_code).toBe(SwapBridgeErrorCode.Unknown); }); it('should handle missing token symbols', () => { diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index aac60d6cb18..4a89459696f 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -355,6 +355,13 @@ export const getEVMTxPropertiesFromTransactionMeta = ( usd_actual_gas: 0, action_type: MetricsActionType.SWAPBRIDGE_V1, ...(transactionMeta.batchId ? { batch_id: transactionMeta.batchId } : {}), + ...getHashPresenceProperties(transactionMeta.hash, undefined), + failure_phase: transactionMeta.hash + ? FailurePhase.SourceExecution + : FailurePhase.Broadcast, + error_code: transactionMeta.error + ? SwapBridgeErrorCode.Unknown + : SwapBridgeErrorCode.MissingErrorObject, }; }; From 5fb3c801a75fa6cb9a50b6531921db991ad4090f Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Tue, 25 Aug 2026 11:42:53 +0200 Subject: [PATCH 2/5] chore(bridge): add PR link to failure telemetry emit changelog --- packages/bridge-controller/CHANGELOG.md | 2 +- packages/bridge-status-controller/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 484dbdd0cb2..295265d1594 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional `failure_phase` and `error_code` on Quotes Error and Failed event context types - Optional `source_hash_present` and `destination_hash_present` on Failed, Submitted, and Completed event context types - Submit and status classifiers live in `@metamask/bridge-status-controller` -- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error and Failed events +- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error and Failed events ([#9949](https://github.com/MetaMask/core/pull/9949)) ## [80.0.0] diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index d9ec4c83536..1f8c73907f1 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -12,7 +12,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add SwapBridge submit and status failure telemetry classifiers for a later emit ([#9947](https://github.com/MetaMask/core/pull/9947)) - New exports: `getHashPresenceProperties`, `getStatusFailurePhase`, `getSubmitErrorCode`, `getSubmitFailureTelemetry`, and `getStatusFailureTelemetry` - Classifies submit and status failures from the code path (not from `error_message`) -- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on SwapBridge Submitted, Completed, and Failed events +- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on SwapBridge Submitted, Completed, and Failed events ([#9949](https://github.com/MetaMask/core/pull/9949)) ## [75.3.0] From bf47560fc5471c520e0885e390fef9fb0089cdff Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Wed, 26 Aug 2026 12:14:15 +0200 Subject: [PATCH 3/5] fix(bridge-status-controller): align Failed failure_phase with combined hash presence Promote broadcast, poll, or unknown to source_execution when history already has a source hash, so Mixpanel does not report a pre-broadcast failure after a source hash exists. --- .../src/utils/metrics/failure-telemetry.ts | 2 + .../bridge-status-controller.test.ts.snap | 10 ++-- .../src/bridge-status-controller.test.ts | 4 +- .../src/bridge-status-controller.ts | 41 ++++++++++------ .../bridge-status-controller/src/index.ts | 1 + .../src/utils/metrics.test.ts | 48 +++++++++++++++++++ .../src/utils/metrics.ts | 26 ++++++++++ 7 files changed, 110 insertions(+), 22 deletions(-) diff --git a/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts index 9d3c213275d..325b8c873f9 100644 --- a/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts +++ b/packages/bridge-controller/src/utils/metrics/failure-telemetry.ts @@ -4,6 +4,8 @@ import { SwapBridgeErrorCode } from './constants.js'; * Classify a thrown value for Quotes Error. Quote fetch always stays in the * `quote` phase; this only chooses `error_code`. * + * Submit and status failure classifiers live in `@metamask/bridge-status-controller`. + * * @param error - The thrown value from quote fetch. * @returns The Mixpanel `error_code`. */ diff --git a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap index 93d6d526cd1..1f08f1eabec 100644 --- a/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap +++ b/packages/bridge-status-controller/src/__snapshots__/bridge-status-controller.test.ts.snap @@ -6490,7 +6490,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -6545,7 +6545,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -6601,7 +6601,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction dropped. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -6704,7 +6704,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, @@ -6753,7 +6753,7 @@ exports[`BridgeStatusController subscription handlers TransactionController:tran "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. approval-tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "unified_swap_bridge", "gas_included": false, "gas_included_7702": false, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.test.ts index 0c8d034bd10..4666bf6d4c0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -5900,7 +5900,7 @@ describe('BridgeStatusController', () => { "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "quick_buy_follow_trading", "gas_included": false, "gas_included_7702": false, @@ -5982,7 +5982,7 @@ describe('BridgeStatusController', () => { "destination_transaction": "FAILED", "error_code": "unknown", "error_message": "Transaction failed. tx-error", - "failure_phase": "broadcast", + "failure_phase": "source_execution", "feature_id": "quick_buy_explore", "gas_included": false, "gas_included_7702": false, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 1ae5431e442..e6fcae63cb8 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -100,6 +100,7 @@ import { getHashPresenceProperties, getSubmitFailureTelemetry, getStatusFailureTelemetry, + promoteFailurePhase, } from './utils/metrics.js'; import { getSelectedChainId } from './utils/network.js'; import { @@ -1839,6 +1840,27 @@ export class BridgeStatusController extends StaticIntervalPollingController { @@ -1258,6 +1259,53 @@ describe('metrics utils', () => { }); }); + describe('promoteFailurePhase', () => { + it('keeps broadcast when no hashes are present', () => { + expect( + promoteFailurePhase(FailurePhase.Broadcast, { + source_hash_present: false, + destination_hash_present: false, + }), + ).toBe(FailurePhase.Broadcast); + }); + + it('promotes broadcast or poll to source_execution when a source hash is present', () => { + expect( + promoteFailurePhase(FailurePhase.Broadcast, { + source_hash_present: true, + destination_hash_present: false, + }), + ).toBe(FailurePhase.SourceExecution); + expect( + promoteFailurePhase(FailurePhase.Poll, { + source_hash_present: true, + destination_hash_present: false, + }), + ).toBe(FailurePhase.SourceExecution); + expect( + promoteFailurePhase(FailurePhase.Unknown, { + source_hash_present: true, + destination_hash_present: false, + }), + ).toBe(FailurePhase.SourceExecution); + }); + + it('promotes to destination_execution when a dest hash is present', () => { + expect( + promoteFailurePhase(FailurePhase.SourceExecution, { + source_hash_present: true, + destination_hash_present: true, + }), + ).toBe(FailurePhase.DestinationExecution); + expect( + promoteFailurePhase(FailurePhase.Broadcast, { + source_hash_present: false, + destination_hash_present: true, + }), + ).toBe(FailurePhase.DestinationExecution); + }); + }); + describe('getSubmitFailureTelemetry', () => { it('uses broadcast for submit failures with no hash', () => { expect(getSubmitFailureTelemetry(new Error('snap failed'))).toStrictEqual( diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 4a89459696f..4d3a0153030 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -414,6 +414,32 @@ export const getStatusFailurePhase = ( return FailurePhase.Poll; }; +/** + * Align `failure_phase` with combined hash presence without turning a + * no-hash `broadcast` failure into `poll`. + * + * @param phase - Phase from the emitting path. + * @param hashPresence - Combined history + caller hash flags. + * @returns The Mixpanel `failure_phase`. + */ +export const promoteFailurePhase = ( + phase: FailurePhase, + hashPresence: HashPresenceProperties, +): FailurePhase => { + if (hashPresence.destination_hash_present) { + return FailurePhase.DestinationExecution; + } + if ( + hashPresence.source_hash_present && + (phase === FailurePhase.Broadcast || + phase === FailurePhase.Poll || + phase === FailurePhase.Unknown) + ) { + return FailurePhase.SourceExecution; + } + return phase; +}; + /** * Telemetry for Failed events emitted from the submit catch (no tx hash yet). * From 9fa5c930386482739777e27aac03b5ce144179d3 Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Wed, 26 Aug 2026 14:31:26 +0200 Subject: [PATCH 4/5] chore(bridge-controller): clarify Quotes Error emit has no hash flags Quote fetch is pre-tx, so the emit changelog should not list source_hash_present or destination_hash_present on Quotes Error. --- packages/bridge-controller/CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 295265d1594..4503a546993 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -14,7 +14,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optional `failure_phase` and `error_code` on Quotes Error and Failed event context types - Optional `source_hash_present` and `destination_hash_present` on Failed, Submitted, and Completed event context types - Submit and status classifiers live in `@metamask/bridge-status-controller` -- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Quotes Error and Failed events ([#9949](https://github.com/MetaMask/core/pull/9949)) +- Emit `failure_phase` and `error_code` on Quotes Error events ([#9949](https://github.com/MetaMask/core/pull/9949)) +- Emit `failure_phase`, `error_code`, `source_hash_present`, and `destination_hash_present` on Failed events ([#9949](https://github.com/MetaMask/core/pull/9949)) ## [80.0.0] From add047418d04a31f47cebfed3d1c5addeb06105f Mon Sep 17 00:00:00 2001 From: Frederic HENG Date: Thu, 27 Aug 2026 18:30:59 +0200 Subject: [PATCH 5/5] fix(bridge-status-controller): define hash helpers before EVM metrics use Move failure classifiers above getEVMTxPropertiesFromTransactionMeta so ESLint no-use-before-define passes. --- .../src/utils/metrics.ts | 222 +++++++++--------- 1 file changed, 111 insertions(+), 111 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/metrics.ts b/packages/bridge-status-controller/src/utils/metrics.ts index 4d3a0153030..748af3d80fa 100644 --- a/packages/bridge-status-controller/src/utils/metrics.ts +++ b/packages/bridge-status-controller/src/utils/metrics.ts @@ -55,6 +55,117 @@ export type FailureTelemetryProperties = HashPresenceProperties & { error_code: SwapBridgeErrorCode; }; +/** + * Classify a thrown value from submit (sign/broadcast) catch paths. + * + * @param error - The thrown value from submit. + * @returns The Mixpanel `error_code`. + */ +export const getSubmitErrorCode = (error: unknown): SwapBridgeErrorCode => { + if (error === undefined || error === null) { + return SwapBridgeErrorCode.MissingErrorObject; + } + if (error instanceof Error) { + return SwapBridgeErrorCode.Unknown; + } + return SwapBridgeErrorCode.NonErrorRejection; +}; + +/** + * @param sourceHash - Source tx hash if known at emit time. + * @param destinationHash - Destination tx hash if known at emit time. + * @returns Boolean hash-presence properties. + */ +export const getHashPresenceProperties = ( + sourceHash?: string | null, + destinationHash?: string | null, +): HashPresenceProperties => { + return { + source_hash_present: Boolean(sourceHash), + destination_hash_present: Boolean(destinationHash), + }; +}; + +/** + * Prefer destination_execution over source_execution over poll. + * + * @param hashPresence - Hash presence at emit time. + * @returns The Mixpanel `failure_phase` for a status/polling Failed event. + */ +export const getStatusFailurePhase = ( + hashPresence: HashPresenceProperties, +): FailurePhase => { + if (hashPresence.destination_hash_present) { + return FailurePhase.DestinationExecution; + } + if (hashPresence.source_hash_present) { + return FailurePhase.SourceExecution; + } + return FailurePhase.Poll; +}; + +/** + * Align `failure_phase` with combined hash presence without turning a + * no-hash `broadcast` failure into `poll`. + * + * @param phase - Phase from the emitting path. + * @param hashPresence - Combined history + caller hash flags. + * @returns The Mixpanel `failure_phase`. + */ +export const promoteFailurePhase = ( + phase: FailurePhase, + hashPresence: HashPresenceProperties, +): FailurePhase => { + if (hashPresence.destination_hash_present) { + return FailurePhase.DestinationExecution; + } + if ( + hashPresence.source_hash_present && + (phase === FailurePhase.Broadcast || + phase === FailurePhase.Poll || + phase === FailurePhase.Unknown) + ) { + return FailurePhase.SourceExecution; + } + return phase; +}; + +/** + * Telemetry for Failed events emitted from the submit catch (no tx hash yet). + * + * @param error - The thrown value from submit. + * @returns Phase, error code, and hash-presence flags. + */ +export const getSubmitFailureTelemetry = ( + error: unknown, +): FailureTelemetryProperties => { + return { + failure_phase: FailurePhase.Broadcast, + error_code: getSubmitErrorCode(error), + source_hash_present: false, + destination_hash_present: false, + }; +}; + +/** + * Telemetry for Failed events derived from a status poll. + * + * @param sourceHash - Source tx hash if known. + * @param destinationHash - Destination tx hash if known. + * @returns Phase, error code, and hash-presence flags. + */ +export const getStatusFailureTelemetry = ( + sourceHash?: string | null, + destinationHash?: string | null, +): FailureTelemetryProperties => { + const hashPresence = getHashPresenceProperties(sourceHash, destinationHash); + return { + ...hashPresence, + failure_phase: getStatusFailurePhase(hashPresence), + error_code: SwapBridgeErrorCode.StatusFailedWithoutReason, + }; +}; + export const getTxStatusesFromHistory = ({ status, hasApprovalTx, @@ -364,114 +475,3 @@ export const getEVMTxPropertiesFromTransactionMeta = ( : SwapBridgeErrorCode.MissingErrorObject, }; }; - -/** - * Classify a thrown value from submit (sign/broadcast) catch paths. - * - * @param error - The thrown value from submit. - * @returns The Mixpanel `error_code`. - */ -export const getSubmitErrorCode = (error: unknown): SwapBridgeErrorCode => { - if (error === undefined || error === null) { - return SwapBridgeErrorCode.MissingErrorObject; - } - if (error instanceof Error) { - return SwapBridgeErrorCode.Unknown; - } - return SwapBridgeErrorCode.NonErrorRejection; -}; - -/** - * @param sourceHash - Source tx hash if known at emit time. - * @param destinationHash - Destination tx hash if known at emit time. - * @returns Boolean hash-presence properties. - */ -export const getHashPresenceProperties = ( - sourceHash?: string | null, - destinationHash?: string | null, -): HashPresenceProperties => { - return { - source_hash_present: Boolean(sourceHash), - destination_hash_present: Boolean(destinationHash), - }; -}; - -/** - * Prefer destination_execution over source_execution over poll. - * - * @param hashPresence - Hash presence at emit time. - * @returns The Mixpanel `failure_phase` for a status/polling Failed event. - */ -export const getStatusFailurePhase = ( - hashPresence: HashPresenceProperties, -): FailurePhase => { - if (hashPresence.destination_hash_present) { - return FailurePhase.DestinationExecution; - } - if (hashPresence.source_hash_present) { - return FailurePhase.SourceExecution; - } - return FailurePhase.Poll; -}; - -/** - * Align `failure_phase` with combined hash presence without turning a - * no-hash `broadcast` failure into `poll`. - * - * @param phase - Phase from the emitting path. - * @param hashPresence - Combined history + caller hash flags. - * @returns The Mixpanel `failure_phase`. - */ -export const promoteFailurePhase = ( - phase: FailurePhase, - hashPresence: HashPresenceProperties, -): FailurePhase => { - if (hashPresence.destination_hash_present) { - return FailurePhase.DestinationExecution; - } - if ( - hashPresence.source_hash_present && - (phase === FailurePhase.Broadcast || - phase === FailurePhase.Poll || - phase === FailurePhase.Unknown) - ) { - return FailurePhase.SourceExecution; - } - return phase; -}; - -/** - * Telemetry for Failed events emitted from the submit catch (no tx hash yet). - * - * @param error - The thrown value from submit. - * @returns Phase, error code, and hash-presence flags. - */ -export const getSubmitFailureTelemetry = ( - error: unknown, -): FailureTelemetryProperties => { - return { - failure_phase: FailurePhase.Broadcast, - error_code: getSubmitErrorCode(error), - source_hash_present: false, - destination_hash_present: false, - }; -}; - -/** - * Telemetry for Failed events derived from a status poll. - * - * @param sourceHash - Source tx hash if known. - * @param destinationHash - Destination tx hash if known. - * @returns Phase, error code, and hash-presence flags. - */ -export const getStatusFailureTelemetry = ( - sourceHash?: string | null, - destinationHash?: string | null, -): FailureTelemetryProperties => { - const hashPresence = getHashPresenceProperties(sourceHash, destinationHash); - return { - ...hashPresence, - failure_phase: getStatusFailurePhase(hashPresence), - error_code: SwapBridgeErrorCode.StatusFailedWithoutReason, - }; -};