From 5e4608f1b23a72132133cfe9a192a877147886d5 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 16:09:42 -0700 Subject: [PATCH 01/12] fix: baseline unit tests --- .../bridge-status-controller.test.ts.snap | 113 ++++++++++++------ .../bridge-status-controller.intent.test.ts | 33 ++++- .../src/bridge-status-controller.test.ts | 48 ++++++-- 3 files changed, 146 insertions(+), 48 deletions(-) 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 b518d8ed036..774449e7538 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 @@ -422,7 +422,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHar exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHardwareWalletDelay for hardware wallet on mobile 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -629,7 +629,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHar "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": true, @@ -680,7 +680,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should call handleMobileHar "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": true, @@ -744,7 +744,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting base approval 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -951,7 +951,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -999,7 +999,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -1063,7 +1063,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti exports[`BridgeStatusController submitTx: EVM bridge should delay after submitting linea approval 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -1270,7 +1270,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -1318,7 +1318,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -1679,7 +1679,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay on extension 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -1886,7 +1886,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -1937,7 +1937,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2001,7 +2001,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobileHardwareWalletDelay with true for non-hardware wallet on mobile 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2208,7 +2208,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2259,7 +2259,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2323,7 +2323,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567893.458", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2530,7 +2530,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2581,7 +2581,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2632,7 +2632,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567893.458", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2671,7 +2671,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with approval 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567892.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2878,7 +2878,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2929,7 +2929,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2968,7 +2968,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit exports[`BridgeStatusController submitTx: EVM bridge should successfully submit an EVM bridge transaction with no approval 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567891.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -3175,7 +3175,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -3273,7 +3273,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3368,7 +3368,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3485,7 +3485,7 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3536,7 +3536,7 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -4067,7 +4067,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -4118,7 +4118,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567892.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -4153,7 +4153,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an exports[`BridgeStatusController submitTx: EVM swap should successfully submit an EVM swap transaction with no approval 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567891.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4360,7 +4360,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567890.456", + "actionId": "1234567891.456", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -4435,7 +4435,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (Max native token swap) 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567891.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4585,7 +4585,7 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when gasIncluded is true and STX is off (undefined gasLimit) 2`] = ` { "account": "0xaccount1", - "actionId": "1234567890.456", + "actionId": "1234567891.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4871,7 +4871,7 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm "sourceTokenSymbol": "SOL", "status": "submitted", "swapTokenValue": "1", - "time": 1234567890, + "time": 1234567891, "txParams": { "data": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", "from": "0x123...", @@ -5195,6 +5195,43 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit [ "TransactionController:getState", ], + [ + "BridgeController:trackUnifiedSwapBridgeEvent", + "Unified SwapBridge Completed", + { + "action_type": "swapbridge-v1", + "actual_time_minutes": 0, + "allowance_reset_transaction": undefined, + "approval_transaction": undefined, + "chain_id_destination": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "chain_id_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "custom_slippage": true, + "destination_transaction": "PENDING", + "gas_included": false, + "gas_included_7702": false, + "is_hardware_wallet": true, + "location": "Main View", + "price_impact": 0, + "provider": "test-bridge_undefined", + "quote_vs_execution_ratio": 0, + "quoted_time_minutes": 5, + "quoted_vs_used_gas_ratio": 0, + "security_warnings": [], + "slippage_limit": 0, + "source_transaction": "COMPLETE", + "stx_enabled": false, + "swap_type": "single_chain", + "token_address_destination": "eip155:1399811149/slip44:501", + "token_address_source": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/slip44:501", + "token_symbol_destination": "USDC", + "token_symbol_source": "SOL", + "usd_actual_gas": 0, + "usd_actual_return": 0, + "usd_amount_source": 100, + "usd_quoted_gas": 5, + "usd_quoted_return": 1000, + }, + ], ] `; @@ -5219,7 +5256,7 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit "sourceTokenSymbol": "SOL", "status": "submitted", "swapTokenValue": "1", - "time": 1234567890, + "time": 1234567891, "txParams": { "data": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAHDXLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHoHp/dmeDvstKbICW3ezeGR69t3/PTAvdXgZVdJFJXaxkoKXUTWfEAyQyCCG9nwVoDsd10OFdnM9ldSi+9SLqHpqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVOn64oNsFdeUvD139j1M51iRmUY839Y25ET4jDRscT081oGb+rLnywLjLSrIQx6MkqNBhCFbxqY1YmoGZVORW/QMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAjJclj04kifG7PRApFI4NgwtaE5na/xCEBI572Nvp+FkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAbd9uHXZaGT2cvhRs7reawctIXtX1s3kTqM9YV+/wCpBHnVW/IxwG7udMVuzmgVB/2xst6j9I5RArHNola8E4+0P/on9df2SnTAmx8pWHneSwmrNt/J3VFLMhqns4zl6JmXkZ+niuxMhAGrmKBaBo94uMv2Sl+Xh3i+VOO0m5BdNZ1ElenbwQylHQY+VW1ydG1MaUEeNpG+EVgswzPMwPoLBgAFAsBcFQAGAAkDQA0DAAAAAAAHBgABAhMICQAHBgADABYICQEBCAIAAwwCAAAAUEYVOwAAAAAJAQMBEQoUCQADBAETCgsKFw0ODxARAwQACRQj5RfLl3rjrSoBAAAAQ2QAAVBGFTsAAAAAyYZnBwAAAABkAAAJAwMAAAEJDAkAAAIBBBMVCQjGASBMKQwnooTbKNxdBwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOBgAAAAAAAAAAAAAAAAAQgAAAAAAAAAAAAAAAAAAAAAAAEIF7RFOAwAAAAAAAAAAAAAAaAIAAAAAAAC4CwAAAAAAAOAA2mcAAAAAAAAAAAAAAAAAAAAApapuIXG0FuHSfsU8qME9s/kaic0AAwGCsZdSuxV5eCm+Ria4LEQPgTg4bg65gNrTAefEzpAfPQgCABIMAgAAAAAAAAAAAAAACAIABQwCAAAAsIOFAAAAAAADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAAxEqIPdq2eRt/F6mHNmFe7iwZpdrtGmHNJMFlK7c6Bc6k6kjBezr6u/tAgvu3OGsJSwSElmcOHZ21imqH/rhJ2KgqDJdBPFH4SYIM1kBAAA=", "from": "0x123...", @@ -5596,7 +5633,7 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "sourceTokenSymbol": "USDT", "status": "submitted", "swapTokenValue": "1", - "time": 1234567890, + "time": 1234567892, "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", @@ -5817,7 +5854,7 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "sourceTokenSymbol": "USDT", "status": "submitted", "swapTokenValue": "1", - "time": 1234567890, + "time": 1234567892, "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index b95ba23110b..097a9d876ae 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -73,7 +73,14 @@ const minimalIntentQuoteResponse = (overrides?: Partial): any => { featureId: undefined, approval: undefined, resetApproval: undefined, - trade: '0xdeadbeef', + trade: { + chainId: 1, + from: '0x9008D19f58AAbd9eD0D60971565AA8510560ab4a', + to: '0x0000000000000000000000000000000000000001', + data: '0x', + value: '0x0', + gasLimit: 21000, + }, ...overrides, }; }; @@ -326,6 +333,7 @@ describe('BridgeStatusController (intent swaps)', () => { }); it('submitIntent: completes when approval tx confirms', async () => { + jest.spyOn(Date, 'now').mockReturnValue(1773879217428); const { controller, accountAddress } = setup({ approvalStatus: TransactionStatus.confirmed, }); @@ -356,7 +364,28 @@ describe('BridgeStatusController (intent swaps)', () => { quoteResponse, accountAddress, }), - ).resolves.toBeDefined(); + ).resolves.toMatchInlineSnapshot(` + { + "chainId": "0x1", + "hash": undefined, + "id": "intentDisplayTxId1", + "isIntentTx": true, + "networkClientId": "network-client-id-1", + "orderUid": "order-uid-approve-1", + "status": "submitted", + "time": 1773879217428, + "txParams": { + "chainId": "0x1", + "data": "0xpprove-1", + "from": "0xAccount1", + "gas": "0x5208", + "gasPrice": "0x3b9aca00", + "to": "0x9008D19f58AAbd9eD0D60971565AA8510560ab41", + "value": "0x0", + }, + "type": "swap", + } + `); expect(submitIntentSpy).toHaveBeenCalled(); }); 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 0bb67e4537d..9721d7227d5 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -1843,7 +1843,8 @@ describe('BridgeStatusController', () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); - jest.spyOn(Date, 'now').mockReturnValue(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); mockMessengerCall = jest.fn(); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -1852,6 +1853,10 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); mockMessengerCall.mockImplementationOnce(jest.fn()); // track event mockMessengerCall.mockResolvedValueOnce('signature'); + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); + mockMessengerCall.mockReturnValueOnce({ + transactions: [], + }); const { controller, startPollingForBridgeTxStatusSpy } = getController(mockMessengerCall); @@ -2055,7 +2060,8 @@ describe('BridgeStatusController', () => { jest.clearAllMocks(); jest.clearAllTimers(); mockMessengerCall = jest.fn(); - jest.spyOn(Date, 'now').mockReturnValue(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -2065,6 +2071,7 @@ describe('BridgeStatusController', () => { mockMessengerCall.mockResolvedValueOnce({ signature: 'signature', }); + mockMessengerCall.mockReturnValueOnce(mockSolanaAccount); mockMessengerCall.mockReturnValueOnce({ transactions: [], }); @@ -2279,7 +2286,9 @@ describe('BridgeStatusController', () => { beforeEach(() => { jest.clearAllMocks(); jest.clearAllTimers(); - jest.spyOn(Date, 'now').mockReturnValue(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); mockMessengerCall = jest.fn(); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -2465,8 +2474,13 @@ describe('BridgeStatusController', () => { jest.clearAllMocks(); jest.clearAllTimers(); mockMessengerCall = jest.fn(); - jest.spyOn(Date, 'now').mockReturnValue(1234567890); - jest.spyOn(Math, 'random').mockReturnValue(0.456); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567893); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.458); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -3308,8 +3322,11 @@ describe('BridgeStatusController', () => { beforeEach(() => { jest.clearAllMocks(); mockMessengerCall = jest.fn(); - jest.spyOn(Date, 'now').mockReturnValue(1234567890); - jest.spyOn(Math, 'random').mockReturnValue(0.456); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); + jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -4875,6 +4892,10 @@ describe('BridgeStatusController', () => { it('should not append auth token to status request when getBearerToken throws an error', async () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + consoleFnSpy = jest + .spyOn(console, 'error') + .mockImplementationOnce(jest.fn()); + consoleFnSpy.mockImplementationOnce(jest.fn()); messengerCallSpy.mockImplementation(() => { throw new Error( @@ -4915,7 +4936,18 @@ describe('BridgeStatusController', () => { headers: { 'X-Client-Id': BridgeClientId.EXTENSION }, }, ); - expect(consoleFnSpy).not.toHaveBeenCalled(); + expect(consoleFnSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + [ + "Error getting JWT token for bridge-api request", + [Error: AuthenticationController:getBearerToken not implemented], + ], + ] + `); }); }); }); From 1e223ca56851cc8f6cc05d8a786344e35a07c062 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 13:06:23 -0700 Subject: [PATCH 02/12] refactor: extract account utils squash accounts squash accounts accounts --- .../src/utils/metrics/properties.ts | 12 ++++++------ .../src/bridge-status-controller.intent.test.ts | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index 8f3ee9afe4d..b66ec12e498 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -22,6 +22,12 @@ import { formatChainIdToCaip, } from '../caip-formatters'; +export const isHardwareWallet = ( + selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], +) => { + return selectedAccount?.metadata?.keyring.type?.includes('Hardware') ?? false; +}; + export const toInputChangedPropertyKey: Partial< Record > = { @@ -106,12 +112,6 @@ export const getRequestParams = ({ }; }; -export const isHardwareWallet = ( - selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], -) => { - return selectedAccount?.metadata?.keyring.type?.includes('Hardware') ?? false; -}; - /** * @param slippage - The slippage percentage * @returns Whether the default slippage was overridden by the user diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 097a9d876ae..50b91b84444 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -253,6 +253,7 @@ const setup = (options?: { state: { txHistory: options?.mockTxHistory ?? {}, }, + addTransactionBatchFn: jest.fn(), clientId: options?.clientId ?? BridgeClientId.EXTENSION, fetchFn: (...args: any[]) => mockFetchFn(...args), addTransactionBatchFn: jest.fn(), From 4f377ec2148e6b7f35a756f916a9d664e3179064 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 13:25:21 -0700 Subject: [PATCH 03/12] chore: remove feature flag allowed action (not in use) --- packages/bridge-status-controller/src/types.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index f0b2b3defbc..4f919df596c 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -22,7 +22,6 @@ import type { NetworkControllerGetStateAction, } from '@metamask/network-controller'; import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; -import type { RemoteFeatureFlagControllerGetStateAction } from '@metamask/remote-feature-flag-controller'; import type { HandleSnapRequest } from '@metamask/snaps-controllers'; import type { Infer } from '@metamask/superstruct'; import type { @@ -327,7 +326,6 @@ type AllowedActions = | BridgeControllerAction | GetGasFeeState | AccountsControllerGetAccountByAddressAction - | RemoteFeatureFlagControllerGetStateAction | AuthenticationControllerGetBearerTokenAction | KeyringControllerSignTypedMessageAction; From 1366ca09b4b387cd35bfa9df65651570bacaff75 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 16:13:13 -0700 Subject: [PATCH 04/12] chore: remove statusRequest param from addTxToHistory --- .../bridge-status-controller.intent.test.ts | 19 ++++----------- .../src/bridge-status-controller.test.ts | 24 +++++++------------ .../src/bridge-status-controller.ts | 15 +----------- .../bridge-status-controller/src/types.ts | 1 - .../src/utils/history.ts | 3 +-- 5 files changed, 16 insertions(+), 46 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index 50b91b84444..ce7a81349cb 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -15,8 +15,8 @@ import { BridgeStatusController } from './bridge-status-controller'; import { MAX_ATTEMPTS } from './constants'; import type { BridgeStatusControllerState } from './types'; import * as bridgeStatusUtils from './utils/bridge-status'; +import * as historyUtils from './utils/history'; import * as intentApi from './utils/intent-api'; -import * as transactionUtils from './utils/transaction'; import { IntentOrderStatus } from './utils/validators'; jest @@ -446,11 +446,9 @@ describe('BridgeStatusController (intent swaps)', () => { .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') .mockResolvedValue(intentStatusResponse); - jest - .spyOn(transactionUtils, 'getStatusRequestParams') - .mockImplementation(() => { - throw new Error('boom'); - }); + jest.spyOn(historyUtils, 'getInitialHistoryItem').mockImplementation(() => { + throw new Error('boom'); + }); const quoteResponse = minimalIntentQuoteResponse(); const consoleSpy = jest @@ -980,14 +978,7 @@ describe('BridgeStatusController (subscriptions + bridge polling + wiping)', () // Use deprecated method to create history and start polling (so token exists in controller) controller.startPollingForBridgeTxStatus({ accountAddress, - bridgeTxMeta: { id: 'bridgeToWipe1' } as TransactionMeta, - statusRequest: { - srcChainId: 1, - srcTxHash: '0xsrc', - destChainId: 10, - bridgeId: 'across', - bridge: 'socket', - }, + bridgeTxMeta: { id: 'bridgeToWipe1', hash: '0xsrc' } as TransactionMeta, quoteResponse, slippagePercentage: 0, startTime: Date.now(), 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 9721d7227d5..057604ddafc 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -295,16 +295,8 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ } = {}): StartPollingForBridgeTxStatusArgsSerialized => ({ bridgeTxMeta: { id: txMetaId, + hash: srcTxHash === 'undefined' ? undefined : srcTxHash, } as TransactionMeta, - statusRequest: { - bridgeId: 'lifi', - srcTxHash, - bridge: 'across', - srcChainId, - destChainId, - quote: getMockQuote({ srcChainId, destChainId }), - refuel: false, - }, quoteResponse: { quote: getMockQuote({ srcChainId, destChainId }), trade: { @@ -1077,8 +1069,9 @@ describe('BridgeStatusController', () => { }); // Start polling with args that have no srcTxHash - const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs(); - startPollingArgs.statusRequest.srcTxHash = undefined; + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); // Advance timer to trigger polling @@ -1093,7 +1086,7 @@ describe('BridgeStatusController', () => { expect( bridgeStatusController.state.txHistory.bridgeTxMetaId1.status.srcChain .txHash, - ).toBeUndefined(); + ).toBeFalsy(); // Cleanup jest.restoreAllMocks(); @@ -1229,8 +1222,9 @@ describe('BridgeStatusController', () => { }); // Start polling with no srcTxHash - const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs(); - startPollingArgs.statusRequest.srcTxHash = undefined; + const startPollingArgs = getMockStartPollingForBridgeTxStatusArgs({ + srcTxHash: 'undefined', + }); bridgeStatusController.startPollingForBridgeTxStatus(startPollingArgs); // Verify initial state has no srcTxHash @@ -3189,7 +3183,7 @@ describe('BridgeStatusController', () => { ).toBeUndefined(); expect( controller.state.txHistory[mockActionId].status.srcChain.txHash, - ).toBe(''); // Empty since tx was never submitted + ).toBeUndefined(); // Empty since tx was never submitted expect(startPollingForBridgeTxStatusSpy).toHaveBeenCalledTimes(0); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 6a9d55e5441..211d124cfb0 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -94,7 +94,6 @@ import { getApprovalTraceParams, getTraceParams } from './utils/trace'; import { findAndUpdateTransactionsInBatch, getAddTransactionBatchParams, - getStatusRequestParams, handleApprovalDelay, handleMobileHardwareWalletDelay, generateActionId, @@ -1343,10 +1342,6 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Thu, 12 Mar 2026 16:16:43 -0700 Subject: [PATCH 05/12] refactor: extract non-evm submission non evm submit --- .../src/utils/snaps.test.ts | 212 +++++++++++++++++- .../src/utils/snaps.ts | 34 ++- .../src/utils/transaction.test.ts | 32 ++- 3 files changed, 258 insertions(+), 20 deletions(-) diff --git a/packages/bridge-status-controller/src/utils/snaps.test.ts b/packages/bridge-status-controller/src/utils/snaps.test.ts index 3c01da6d1be..f734b95355d 100644 --- a/packages/bridge-status-controller/src/utils/snaps.test.ts +++ b/packages/bridge-status-controller/src/utils/snaps.test.ts @@ -1,6 +1,8 @@ import { v4 as uuid } from 'uuid'; -import { createClientTransactionRequest } from './snaps'; +import { createClientTransactionRequest, handleNonEvmTx } from './snaps'; +import { ChainId } from '../../../bridge-controller/src/types'; +import { BridgeStatusControllerMessenger } from '../types'; jest.mock('uuid', () => ({ v4: jest.fn(), @@ -12,6 +14,214 @@ describe('Snaps Utils', () => { (uuid as jest.Mock).mockReturnValue('test-uuid-1234'); }); + describe('handleNonEvmTx', () => { + it.each([ + { + snapResponse: { + result: { + signature: 'solanaSignature123', + }, + }, + label: 'result.signature', + }, + { + snapResponse: { + result: { + txid: 'solanaSignature123', + }, + }, + label: 'result.txid', + }, + { + snapResponse: { + result: { + hash: 'solanaSignature123', + }, + }, + label: 'result.hash', + }, + { + snapResponse: { + result: { + txHash: 'solanaSignature123', + }, + }, + label: 'result.txHash', + }, + { + snapResponse: { + transactionId: 'solanaSignature123', + }, + label: 'transactionId', + }, + ])( + 'should submit a non-EVM transaction ({label})', + async ({ snapResponse }) => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + + const mockCall = jest.fn((...args: unknown[]) => { + const [action] = args; + if (action === 'SnapController:handleRequest') { + return Promise.resolve(snapResponse); + } + }); + const messenger = { + call: (...args: unknown[]) => mockCall(...args), + } as unknown as BridgeStatusControllerMessenger; + const { time, ...result } = await handleNonEvmTx( + messenger, + transaction, + { + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, + sentAmount: { + amount: '1000000000', + }, + } as never, + { id: accountId, metadata: { snap: { id: snapId } } } as never, + ); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "test-account-id", + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "base64-encoded-transaction", + }, + }, + "snapId": "test-snap-id", + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + { + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": undefined, + "destinationTokenAmount": undefined, + "destinationTokenDecimals": undefined, + "destinationTokenSymbol": "MATIC", + "hash": "solanaSignature123", + "id": "solanaSignature123", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "test-snap-id", + "origin": "test-snap-id", + "sourceTokenAddress": undefined, + "sourceTokenAmount": undefined, + "sourceTokenDecimals": undefined, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1000000000", + "txParams": { + "data": "base64-encoded-transaction", + "from": undefined, + }, + "type": "swap", + } + `); + }, + ); + + it('should submit a non-EVM transaction (no result in response)', async () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + + const mockCall = jest.fn((...args: unknown[]) => { + const [action] = args; + if (action === 'SnapController:handleRequest') { + return Promise.resolve(undefined); + } + }); + const messenger = { + call: (...args: unknown[]) => mockCall(...args), + } as unknown as BridgeStatusControllerMessenger; + const { time, ...result } = await handleNonEvmTx( + messenger, + transaction, + { + quote: { + srcChainId: ChainId.SOLANA, + srcAsset: { symbol: 'SOL' }, + destAsset: { symbol: 'MATIC' }, + }, + sentAmount: { + amount: '1000000000', + }, + } as never, + { id: accountId, metadata: { snap: { id: snapId } } } as never, + ); + + expect(mockCall.mock.calls).toMatchInlineSnapshot(` + [ + [ + "SnapController:handleRequest", + { + "handler": "onClientRequest", + "origin": "metamask", + "request": { + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "signAndSendTransaction", + "params": { + "accountId": "test-account-id", + "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", + "transaction": "base64-encoded-transaction", + }, + }, + "snapId": "test-snap-id", + }, + ], + ] + `); + expect(result).toMatchInlineSnapshot(` + { + "approvalTxId": undefined, + "chainId": "0x416edef1601be", + "destinationChainId": "0x1", + "destinationTokenAddress": undefined, + "destinationTokenAmount": undefined, + "destinationTokenDecimals": undefined, + "destinationTokenSymbol": "MATIC", + "hash": undefined, + "id": "test-uuid-1234", + "isBridgeTx": false, + "isSolana": true, + "networkClientId": "test-snap-id", + "origin": "test-snap-id", + "sourceTokenAddress": undefined, + "sourceTokenAmount": undefined, + "sourceTokenDecimals": undefined, + "sourceTokenSymbol": "SOL", + "status": "submitted", + "swapTokenValue": "1000000000", + "txParams": { + "data": "base64-encoded-transaction", + "from": undefined, + }, + "type": "swap", + } + `); + }); + }); + describe('createClientTransactionRequest', () => { it('should create a proper request without options', () => { const snapId = 'test-snap-id'; diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts index 8d98a5b9e36..9901ee2fefa 100644 --- a/packages/bridge-status-controller/src/utils/snaps.ts +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/explicit-function-return-type */ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { QuoteMetadata, @@ -12,7 +11,9 @@ import { isCrossChain, isTronTrade, } from '@metamask/bridge-controller'; +import { SnapController } from '@metamask/snaps-controllers'; import { + CHAIN_IDS, TransactionStatus, TransactionType, } from '@metamask/transaction-controller'; @@ -68,19 +69,19 @@ export const createClientTransactionRequest = ( * * @param trade - The trade data * @param srcChainId - The source chain ID - * @param accountId - The selected account ID + * @param accountId - The account ID * @param snapId - The snap ID * @returns The snap request object for signing and sending transaction */ export const getClientRequest = ( trade: Trade, srcChainId: number, - accountId: string, + accountId: AccountsControllerState['internalAccounts']['accounts'][string]['id'], snapId: string, -) => { +): Parameters[0] => { const scope = formatChainIdToCaip(srcChainId); - const transactionData = extractTradeData(trade); + const transaction = extractTradeData(trade); // Tron trades need the visible flag and contract type to be included in the request options const options = isTronTrade(trade) @@ -90,14 +91,31 @@ export const getClientRequest = ( } : undefined; - // Use the new unified interface return createClientTransactionRequest( snapId, - transactionData, + transaction, scope, accountId, options, ); + // return { + // // @ts-expect-error - TODO snaps-controller does not export SnapId type (a string) + // snapId, + // origin: 'metamask', + // // @ts-expect-error - TODO snaps-controller does not export HandlerType.OnClientRequest + // handler: 'onClientRequest', + // request: { + // id: uuid(), + // jsonrpc: '2.0', + // method: 'signAndSendTransaction', + // params: { + // transaction, + // scope, + // accountId, + // ...(options && { options }), + // }, + // }, + // }; }; export const getTxMetaFields = ( @@ -114,7 +132,7 @@ export const getTxMetaFields = ( destinationChainId = formatChainIdToHex(quoteResponse.quote.destChainId); } catch { // Fallback for non-EVM destination (shouldn't happen for BTC->EVM) - destinationChainId = '0x1' as `0x${string}`; // Default to mainnet + destinationChainId = CHAIN_IDS.MAINNET; // Default to mainnet } return { diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index 9c72e9fabb2..d40c8346ac1 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -1696,7 +1696,7 @@ describe('Bridge Status Controller Transaction Utils', () => { it('should include Tron options when trade is Tron', () => { const createClientRequestSpy = jest - .spyOn(snaps, 'createClientTransactionRequest') + .spyOn(snaps, 'getClientRequest') .mockReturnValue({ mocked: true } as never); const tronTrade = { @@ -1722,16 +1722,26 @@ describe('Bridge Status Controller Transaction Utils', () => { ); expect(result).toStrictEqual({ mocked: true }); - expect(createClientRequestSpy).toHaveBeenCalledWith( - 'test-snap-id', - expect.any(String), - formatChainIdToCaip(ChainId.TRON), - 'test-account-id', - { - visible: true, - type: 'TransferContract', - }, - ); + expect(createClientRequestSpy.mock.calls).toMatchInlineSnapshot(` + [ + [ + { + "raw_data": { + "contract": [ + { + "type": "TransferContract", + }, + ], + }, + "raw_data_hex": "abcdef", + "visible": true, + }, + 728126428, + "test-account-id", + "test-snap-id", + ], + ] + `); createClientRequestSpy.mockRestore(); }); From f9e13de4b1f27cdad3795b0f401c4202d9477a69 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 16:26:35 -0700 Subject: [PATCH 06/12] refactor: extract gas and transaction utils fix: gas tests refactor: extract more transaction controller utils transaction refactor: move txDataByType to addTransactionBatch util --- .../src/bridge-status-controller.test.ts | 8 + .../src/bridge-status-controller.ts | 303 +++------------ .../src/utils/bridge-status.ts | 21 +- .../src/utils/gas.test.ts | 59 ++- .../bridge-status-controller/src/utils/gas.ts | 82 ++--- .../src/utils/history.test.ts | 106 ++++++ .../src/utils/intent-api.test.ts | 57 ++- .../src/utils/transaction.test.ts | 159 +------- .../src/utils/transaction.ts | 346 ++++++++++++++++-- 9 files changed, 631 insertions(+), 510 deletions(-) create mode 100644 packages/bridge-status-controller/src/utils/history.test.ts 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 057604ddafc..8fcc2465c7d 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -4377,6 +4377,11 @@ describe('BridgeStatusController', () => { describe('TransactionController:transactionFailed', () => { it('should track failed event for bridge transaction', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + // messengerCallSpy.mockReturnValue({ + // transactions: [ + // { id: 'bridgeTxMetaId1', status: TransactionStatus.failed }, + // ], + // }); mockMessenger.publish('TransactionController:transactionFailed', { error: 'tx-error', transactionMeta: { @@ -4602,6 +4607,9 @@ describe('BridgeStatusController', () => { const unknownTxMetaId = 'unknown-tx-meta-id'; const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); + // messengerCallSpy.mockReturnValue({ + // transactions: [{ actionId, status: TransactionStatus.failed }], + // }); // Publish failure with an unknown txMeta.id but with matching actionId mockMessenger.publish('TransactionController:transactionFailed', { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 211d124cfb0..a043fea4077 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -23,18 +23,13 @@ import { PollingStatus, } from '@metamask/bridge-controller'; import type { TraceCallback } from '@metamask/controller-utils'; -import { toHex } from '@metamask/controller-utils'; import { StaticIntervalPollingController } from '@metamask/polling-controller'; import { TransactionStatus, TransactionType, -} from '@metamask/transaction-controller'; -import type { - IsAtomicBatchSupportedResultEntry, TransactionController, - TransactionMeta, - TransactionParams, } from '@metamask/transaction-controller'; +import type { TransactionMeta } from '@metamask/transaction-controller'; import { numberToHex } from '@metamask/utils'; import type { Hex } from '@metamask/utils'; @@ -63,7 +58,6 @@ import { getStatusRequestWithSrcTxHash, shouldSkipFetchDueToFetchFailures, } from './utils/bridge-status'; -import { getTxGasEstimates } from './utils/gas'; import { getInitialHistoryItem, rekeyHistoryItemInState, @@ -92,12 +86,17 @@ import { import { handleNonEvmTx } from './utils/snaps'; import { getApprovalTraceParams, getTraceParams } from './utils/trace'; import { - findAndUpdateTransactionsInBatch, getAddTransactionBatchParams, handleApprovalDelay, handleMobileHardwareWalletDelay, generateActionId, waitForTxConfirmation, + getTransactionMetaById, + addTransactionBatch, + addSyntheticTransaction, + getTransactions, + submitEvmTransaction, + checkIsDelegatedAccount, } from './utils/transaction'; const metadata: StateMetadata = { @@ -796,12 +795,7 @@ export class BridgeStatusController extends StaticIntervalPollingController tx.id === bridgeTxMetaId, - ); + const txMeta = getTransactionMetaById(this.messenger, bridgeTxMetaId); return txMeta?.hash; }; @@ -867,37 +861,6 @@ export class BridgeStatusController extends StaticIntervalPollingController - >['result'], - ): Promise => { - const transactionHash = await hashPromise; - const finalTransactionMeta: TransactionMeta | undefined = this.messenger - .call('TransactionController:getState') - .transactions.find((tx: TransactionMeta) => tx.hash === transactionHash); - if (!finalTransactionMeta) { - throw new Error( - 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', - ); - } - return finalTransactionMeta; - }; - - // Waits until a given transaction (by id) reaches confirmed/finalized status or fails/times out. - readonly #waitForTxConfirmation = async ( - txId: string, - { - timeoutMs = 5 * 60_000, // 5 minutes default - pollMs = 3_000, - }: { timeoutMs?: number; pollMs?: number } = {}, - ): Promise => { - return await waitForTxConfirmation(this.messenger, txId, { - timeoutMs, - pollMs, - }); - }; - readonly #handleApprovalTx = async ( quoteResponse: QuoteResponse & QuoteMetadata, isBridgeTx: boolean, @@ -908,9 +871,16 @@ export class BridgeStatusController extends StaticIntervalPollingController => { if (approval && isEvmTxData(approval)) { const approveTx = async (): Promise => { - await this.#handleUSDTAllowanceReset(resetApproval); + if (resetApproval) { + await submitEvmTransaction({ + messenger: this.messenger, + transactionType: TransactionType.bridgeApproval, + trade: resetApproval, + }); + } - const approvalTxMeta = await this.#handleEvmTransaction({ + const approvalTxMeta = await submitEvmTransaction({ + messenger: this.messenger, transactionType: isBridgeTx ? TransactionType.bridgeApproval : TransactionType.swapApproval, @@ -931,138 +901,7 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - // Use provided actionId (for pre-submission history) or generate one - const actionId = providedActionId ?? generateActionId(); - const selectedAccount = getAccountByAddress(this.messenger, trade.from); - if (!selectedAccount) { - throw new Error( - 'Failed to submit cross-chain swap transaction: unknown account in trade data', - ); - } - const hexChainId = formatChainIdToHex(trade.chainId); - const networkClientId = getNetworkClientIdByChainId( - this.messenger, - hexChainId, - ); - - const requestOptions = { - actionId, - networkClientId, - requireApproval, - type: transactionType, - origin: 'metamask', - }; - // Exclude gasLimit from trade to avoid type issues (it can be null) - const { gasLimit: tradeGasLimit, ...tradeWithoutGasLimit } = trade; - - const transactionParams: Parameters< - TransactionController['addTransaction'] - >[0] = { - ...tradeWithoutGasLimit, - chainId: hexChainId, - // Only add gasLimit and gas if they're valid (not undefined/null/zero) - ...(tradeGasLimit && - tradeGasLimit !== 0 && { - gasLimit: tradeGasLimit.toString(), - gas: tradeGasLimit.toString(), - }), - }; - const transactionParamsWithMaxGas: TransactionParams = { - ...transactionParams, - ...(await this.#calculateGasFees( - transactionParams, - networkClientId, - hexChainId, - txFee, - )), - }; - - const { result } = await this.messenger.call( - 'TransactionController:addTransaction', - transactionParamsWithMaxGas, - requestOptions, - ); - - return await this.#waitForHashAndReturnFinalTxMeta(result); - }; - - readonly #handleUSDTAllowanceReset = async ( - resetApproval?: TxData, - ): Promise => { - if (resetApproval) { - await this.#handleEvmTransaction({ - transactionType: TransactionType.bridgeApproval, - trade: resetApproval, - }); - } - }; - - readonly #calculateGasFees = async ( - transactionParams: TransactionParams, - networkClientId: string, - chainId: Hex, - txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, - ): Promise<{ - maxFeePerGas: Hex; - maxPriorityFeePerGas: Hex; - gas?: Hex; - }> => { - const { gas } = transactionParams; - // If txFee is provided (gasIncluded case), use the quote's gas fees - // Convert to hex since txFee values from the quote are decimal strings - if (txFee) { - return { - maxFeePerGas: toHex(txFee.maxFeePerGas), - maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas), - gas: gas ? toHex(gas) : undefined, - }; - } - - const { gasFeeEstimates } = this.messenger.call( - 'GasFeeController:getState', - ); - const { estimates: txGasFeeEstimates } = await this.messenger.call( - 'TransactionController:estimateGasFee', - { transactionParams, chainId, networkClientId }, - ); - const { maxFeePerGas, maxPriorityFeePerGas } = getTxGasEstimates({ - networkGasFeeEstimates: gasFeeEstimates, - txGasFeeEstimates, - }); - - return { - maxFeePerGas, - maxPriorityFeePerGas, - gas: gas ? toHex(gas) : undefined, - }; - }; - + // TODO simplify and make more readable /** * Submits batched EVM transactions to the TransactionController * @@ -1088,36 +927,12 @@ export class BridgeStatusController extends StaticIntervalPollingController type === TransactionType.bridgeApproval, - )?.params.data, - [TransactionType.swapApproval]: transactionParams.transactions.find( - ({ type }) => type === TransactionType.swapApproval, - )?.params.data, - [TransactionType.bridge]: transactionParams.transactions.find( - ({ type }) => type === TransactionType.bridge, - )?.params.data, - [TransactionType.swap]: transactionParams.transactions.find( - ({ type }) => type === TransactionType.swap, - )?.params.data, - }; - - const { batchId } = await this.#addTransactionBatchFn(transactionParams); - - const { approvalMeta, tradeMeta } = findAndUpdateTransactionsInBatch({ - messenger: this.messenger, - batchId, - txDataByType, - }); - - if (!tradeMeta) { - throw new Error( - 'Failed to update cross-chain swap transaction batch: tradeMeta not found', - ); - } - return { approvalMeta, tradeMeta }; + return await addTransactionBatch( + this.messenger, + this.#addTransactionBatchFn, + transactionParams, + ); }; /** @@ -1277,24 +1092,11 @@ export class BridgeStatusController extends StaticIntervalPollingController => { - try { - const atomicBatchSupport = await this.messenger.call( - 'TransactionController:isAtomicBatchSupported', - { - address: (quoteResponse.trade as TxData) - .from as `0x${string}`, - chainIds: [hexChainId], - }, - ); - return atomicBatchSupport.some( - (entry: IsAtomicBatchSupportedResultEntry) => - entry.isSupported && entry.delegationAddress, - ); - } catch { - return false; - } - })(); + isDelegatedAccount = await checkIsDelegatedAccount( + this.messenger, + quoteResponse.trade.from as `0x`, + [hexChainId], + ); if ( isStxEnabledOnClient || @@ -1356,7 +1158,8 @@ export class BridgeStatusController extends StaticIntervalPollingController tx.id === txMetaId, ); - const approvalTxMeta = transactions?.find( - (tx: TransactionMeta) => tx.id === historyItem.approvalTxId, + const approvalTxMeta = transactions.find( + (tx: TransactionMeta) => tx.id === approvalTxId, ); const requiredEventProperties = { @@ -1736,7 +1525,7 @@ export class BridgeStatusController extends StaticIntervalPollingController @@ -110,3 +111,21 @@ export const shouldSkipFetchDueToFetchFailures = ( } return false; }; + +/** + * @deprecated Use getStatusRequestWithSrcTxHash instead + * @param quoteResponse - The quote response to get the status request parameters from + * @returns The status request parameters + */ +export const getStatusRequestParams = ( + quoteResponse: QuoteResponse, +): StatusRequest => { + return { + bridgeId: quoteResponse.quote.bridgeId, + bridge: quoteResponse.quote.bridges[0], + srcChainId: quoteResponse.quote.srcChainId, + destChainId: quoteResponse.quote.destChainId, + quote: quoteResponse.quote, + refuel: Boolean(quoteResponse.quote.refuel), + }; +}; diff --git a/packages/bridge-status-controller/src/utils/gas.test.ts b/packages/bridge-status-controller/src/utils/gas.test.ts index 7ff944f1603..40be132059c 100644 --- a/packages/bridge-status-controller/src/utils/gas.test.ts +++ b/packages/bridge-status-controller/src/utils/gas.test.ts @@ -1,4 +1,7 @@ -import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '@metamask/bridge-controller'; +import { + BRIDGE_PREFERRED_GAS_ESTIMATE, + TxData, +} from '@metamask/bridge-controller'; import type { GasFeeState } from '@metamask/gas-fee-controller'; import type { FeeMarketGasFeeEstimates } from '@metamask/transaction-controller'; import { GasFeeEstimateLevel } from '@metamask/transaction-controller'; @@ -27,11 +30,24 @@ const mockNetworkGasFeeEstimates = { estimatedBaseFee: '0.00000001', } as GasFeeState['gasFeeEstimates']; +const mockMessengerCall = jest.fn(); +const mockMessenger = { call: mockMessengerCall }; + describe('gas calculation utils', () => { describe('getTxGasEstimates', () => { - it('should return gas fee estimates with baseAndPriorityFeePerGas when maxPriorityFeePerGas is provided', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return gas fee estimates with baseAndPriorityFeePerGas when maxPriorityFeePerGas is provided', async () => { + mockMessenger.call.mockReturnValueOnce({ + gasFeeEstimates: mockNetworkGasFeeEstimates, + }); + mockMessenger.call.mockReturnValueOnce({ + estimates: mockTxGasFeeEstimates, + }); // Call the function - const result = getTxGasEstimates({ + const result = await getTxGasEstimates(mockMessenger, { txGasFeeEstimates: mockTxGasFeeEstimates, networkGasFeeEstimates: mockNetworkGasFeeEstimates, }); @@ -46,13 +62,17 @@ describe('gas calculation utils', () => { }); }); - it('should handle missing property in txGasFeeEstimates', () => { - const result = getTxGasEstimates({ - txGasFeeEstimates: {} as never, - networkGasFeeEstimates: { + it('should handle missing property in txGasFeeEstimates', async () => { + mockMessenger.call.mockReturnValueOnce({ + gasFeeEstimates: { estimatedBaseFee: '0.00000001', } as GasFeeState['gasFeeEstimates'], }); + mockMessenger.call.mockReturnValueOnce({ + estimates: {}, + }); + + const result = await getTxGasEstimates(mockMessenger); expect(result).toStrictEqual({ baseAndPriorityFeePerGas: undefined, @@ -61,7 +81,7 @@ describe('gas calculation utils', () => { }); }); - it('should use Bridge preferred gas estimate as gas estimates', () => { + it('should use Bridge preferred gas estimate as gas estimates', async () => { const estimates = { type: 'fee-market', [GasFeeEstimateLevel.Low]: { @@ -77,7 +97,15 @@ describe('gas calculation utils', () => { maxPriorityFeePerGas: '0xHIGH_PRIORITY', }, } as FeeMarketGasFeeEstimates; - const result = getTxGasEstimates({ + + mockMessenger.call.mockReturnValueOnce({ + gasFeeEstimates: mockNetworkGasFeeEstimates, + }); + mockMessenger.call.mockReturnValueOnce({ + estimates, + }); + + const result = await getTxGasEstimates(mockMessenger, { txGasFeeEstimates: estimates, networkGasFeeEstimates: mockNetworkGasFeeEstimates, }); @@ -90,11 +118,18 @@ describe('gas calculation utils', () => { ); }); - it('should use default estimatedBaseFee when not provided in networkGasFeeEstimates', () => { + it('should use default estimatedBaseFee when not provided in networkGasFeeEstimates', async () => { // Mock data + mockMessengerCall.mockClear(); + mockMessengerCall.mockReturnValueOnce({ + gasFeeEstimates: {}, + }); + mockMessengerCall.mockResolvedValueOnce({ + estimates: mockTxGasFeeEstimates, + }); // Call the function - const result = getTxGasEstimates({ + const result = await getTxGasEstimates(mockMessenger, { txGasFeeEstimates: mockTxGasFeeEstimates, networkGasFeeEstimates: {}, }); @@ -111,7 +146,7 @@ describe('gas calculation utils', () => { }); describe('calculateGasFees', () => { - const mockTrade = { + const mockTrade: TxData = { chainId: 1, gasLimit: 1231, to: '0x1', diff --git a/packages/bridge-status-controller/src/utils/gas.ts b/packages/bridge-status-controller/src/utils/gas.ts index 3df9189e4f3..2fdca19dc50 100644 --- a/packages/bridge-status-controller/src/utils/gas.ts +++ b/packages/bridge-status-controller/src/utils/gas.ts @@ -1,31 +1,44 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { BRIDGE_PREFERRED_GAS_ESTIMATE } from '@metamask/bridge-controller'; import type { TokenAmountValues, TxData } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; -import type { - GasFeeEstimates, - GasFeeState, -} from '@metamask/gas-fee-controller'; -import type { - FeeMarketGasFeeEstimates, - TransactionController, - TransactionReceipt, -} from '@metamask/transaction-controller'; +import type { TransactionReceipt } from '@metamask/transaction-controller'; +import { TransactionController } from '@metamask/transaction-controller'; import type { Hex } from '@metamask/utils'; import { BigNumber } from 'bignumber.js'; +import { getGasFeeEstimates } from './transaction'; import type { BridgeHistoryItem, BridgeStatusControllerMessenger, } from '../types'; -const getTransaction1559GasFeeEstimates = ( - txGasFeeEstimates: FeeMarketGasFeeEstimates, - estimatedBaseFee: string, +/** + * Get the gas fee estimates for a transaction + * + * @param messenger - The messenger for the gas fee estimates + * @param estimateGasFeeParams - The parameters for the {@link TransactionController.estimateGasFee} method + + * @returns The gas fee estimates for the transaction + */ +export const getTxGasEstimates = async ( + messenger: BridgeStatusControllerMessenger, + estimateGasFeeParams: Parameters[0], ) => { - const { maxFeePerGas, maxPriorityFeePerGas } = - txGasFeeEstimates?.[BRIDGE_PREFERRED_GAS_ESTIMATE] ?? {}; + const { gasFeeEstimates } = messenger.call('GasFeeController:getState'); + const estimatedBaseFee = + 'estimatedBaseFee' in gasFeeEstimates + ? gasFeeEstimates.estimatedBaseFee + : '0'; + + // Get transaction's 1559 gas fee estimates + const { maxFeePerGas, maxPriorityFeePerGas } = await getGasFeeEstimates( + messenger, + estimateGasFeeParams, + ); + /** + * @deprecated this is unused + */ const baseAndPriorityFeePerGas = maxPriorityFeePerGas ? new BigNumber(estimatedBaseFee, 10) .times(10 ** 9) @@ -39,30 +52,6 @@ const getTransaction1559GasFeeEstimates = ( }; }; -/** - * Get the gas fee estimates for a transaction - * - * @param params - The parameters for the gas fee estimates - * @param params.txGasFeeEstimates - The gas fee estimates for the transaction (TransactionController) - * @param params.networkGasFeeEstimates - The gas fee estimates for the network (GasFeeController) - * @returns The gas fee estimates for the transaction - */ -export const getTxGasEstimates = ({ - txGasFeeEstimates, - networkGasFeeEstimates, -}: { - txGasFeeEstimates: Awaited< - ReturnType - >['estimates']; - networkGasFeeEstimates: GasFeeState['gasFeeEstimates']; -}) => { - const { estimatedBaseFee = '0' } = networkGasFeeEstimates as GasFeeEstimates; - return getTransaction1559GasFeeEstimates( - txGasFeeEstimates as unknown as FeeMarketGasFeeEstimates, - estimatedBaseFee, - ); -}; - export const calculateGasFees = async ( skipGasFields: boolean, messenger: BridgeStatusControllerMessenger, @@ -84,15 +73,14 @@ export const calculateGasFees = async ( to: trade.to as `0x${string}`, value: trade.value as `0x${string}`, }; - const { gasFeeEstimates } = messenger.call('GasFeeController:getState'); - const { estimates: txGasFeeEstimates } = await messenger.call( - 'TransactionController:estimateGasFee', - { transactionParams, chainId, networkClientId }, + const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates( + messenger, + { + transactionParams, + networkClientId, + chainId, + }, ); - const { maxFeePerGas, maxPriorityFeePerGas } = getTxGasEstimates({ - networkGasFeeEstimates: gasFeeEstimates, - txGasFeeEstimates, - }); const maxGasLimit = toHex(transactionParams.gas ?? 0); return { diff --git a/packages/bridge-status-controller/src/utils/history.test.ts b/packages/bridge-status-controller/src/utils/history.test.ts new file mode 100644 index 00000000000..6d3b097c025 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/history.test.ts @@ -0,0 +1,106 @@ +import { StatusTypes } from '@metamask/bridge-controller'; +import type { Quote } from '@metamask/bridge-controller'; + +import { getHistoryKey, rekeyHistoryItemInState } from './history'; +import type { + BridgeStatusControllerState, + BridgeHistoryItem, + StatusResponse, +} from '../types'; + +describe('History Utils', () => { + describe('rekeyHistoryItemInState', () => { + const makeState = ( + overrides?: Partial, + ): BridgeStatusControllerState => + ({ + txHistory: {}, + ...overrides, + }) as BridgeStatusControllerState; + + it('returns false when history item missing', () => { + const state = makeState(); + const result = rekeyHistoryItemInState(state, 'missing', { + id: 'tx1', + hash: '0xhash', + }); + expect(result).toBe(false); + }); + + it('rekeys and preserves srcTxHash', () => { + const state = makeState({ + txHistory: { + action1: { + txMetaId: undefined, + actionId: 'action1', + originalTransactionId: undefined, + quote: { srcChainId: 1, destChainId: 10 } as Quote, + status: { + status: StatusTypes.SUBMITTED, + srcChain: { chainId: 1, txHash: '0xold' }, + } as StatusResponse, + account: '0xaccount', + estimatedProcessingTimeInSeconds: 1, + slippagePercentage: 0, + hasApprovalTx: false, + } as BridgeHistoryItem, + }, + }); + + const result = rekeyHistoryItemInState(state, 'action1', { + id: 'tx1', + hash: '0xnew', + }); + + expect(result).toBe(true); + expect(state.txHistory.action1).toBeUndefined(); + expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xnew'); + }); + + it('uses existing srcTxHash when txMeta hash is missing', () => { + const state = makeState({ + txHistory: { + action1: { + txMetaId: undefined, + actionId: 'action1', + originalTransactionId: undefined, + quote: { srcChainId: 1, destChainId: 10 } as Quote, + status: { + status: StatusTypes.SUBMITTED, + srcChain: { chainId: 1, txHash: '0xold' }, + } as StatusResponse, + account: '0xaccount', + estimatedProcessingTimeInSeconds: 1, + slippagePercentage: 0, + hasApprovalTx: false, + } as BridgeHistoryItem, + }, + }); + + const result = rekeyHistoryItemInState(state, 'action1', { id: 'tx1' }); + + expect(result).toBe(true); + expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xold'); + }); + }); + + describe('getHistoryKey', () => { + it('returns actionId when both actionId and bridgeTxMetaId are provided', () => { + expect(getHistoryKey('action-123', 'tx-456')).toBe('action-123'); + }); + + it('returns bridgeTxMetaId when only bridgeTxMetaId is provided', () => { + expect(getHistoryKey(undefined, 'tx-456')).toBe('tx-456'); + }); + + it('returns actionId when only actionId is provided', () => { + expect(getHistoryKey('action-123', undefined)).toBe('action-123'); + }); + + it('throws error when neither actionId nor bridgeTxMetaId is provided', () => { + expect(() => getHistoryKey(undefined, undefined)).toThrow( + 'Cannot add tx to history: either actionId or bridgeTxMeta.id must be provided', + ); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/intent-api.test.ts b/packages/bridge-status-controller/src/utils/intent-api.test.ts index 53377db03d8..ef34cc10453 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.test.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.test.ts @@ -1,8 +1,9 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { StatusTypes } from '@metamask/bridge-controller'; +import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { + getIntentFromQuote, IntentApiImpl, mapIntentOrderStatusToTransactionStatus, translateIntentOrderToBridgeStatus, @@ -13,10 +14,10 @@ import type { FetchFunction } from '../types'; describe('IntentApiImpl', () => { const baseUrl = 'https://example.com/api'; - const clientId = 'client-id'; + const clientId = BridgeClientId.MOBILE; const makeParams = (): IntentSubmissionParams => ({ - srcChainId: '1', + srcChainId: 1, quoteId: 'quote-123', signature: '0xsig', order: { some: 'payload' }, @@ -79,7 +80,7 @@ describe('IntentApiImpl', () => { const orderId = 'order-1'; const aggregatorId = 'My Agg/With Spaces'; - const srcChainId = '10'; + const srcChainId = 10; const result = await api.getOrderStatus( orderId, @@ -109,7 +110,7 @@ describe('IntentApiImpl', () => { const fetchFn = makeFetchMock().mockRejectedValue(new Error('nope')); const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect(api.getOrderStatus('o', 'a', '1', clientId)).rejects.toThrow( + await expect(api.getOrderStatus('o', 'a', 1, clientId)).rejects.toThrow( 'Failed to get order status: nope', ); }); @@ -118,7 +119,7 @@ describe('IntentApiImpl', () => { const fetchFn = makeFetchMock().mockRejectedValue({ message: 'nope' }); const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect(api.getOrderStatus('o', 'a', '1', clientId)).rejects.toThrow( + await expect(api.getOrderStatus('o', 'a', 1, clientId)).rejects.toThrow( 'Failed to get order status', ); }); @@ -143,7 +144,7 @@ describe('IntentApiImpl', () => { const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); await expect( - api.getOrderStatus('order-1', 'agg', '1', clientId), + api.getOrderStatus('order-1', 'agg', 1, clientId), ).rejects.toThrow( 'Failed to get order status: Invalid getOrderStatus response', ); @@ -316,4 +317,46 @@ describe('IntentApiImpl', () => { ).toBe(TransactionStatus.failed); }); }); + + describe('getIntentFromQuote', () => { + it('returns intent when present in quote response', () => { + const mockIntent = { protocol: 'cowswap', order: { some: 'data' } }; + const quoteResponse = { + quote: { + intent: mockIntent, + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(getIntentFromQuote(quoteResponse)).toBe(mockIntent); + }); + + it('throws error when intent is missing from quote', () => { + const quoteResponse = { + quote: { + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(() => getIntentFromQuote(quoteResponse)).toThrow( + 'submitIntent: missing intent data', + ); + }); + + it('throws error when intent is undefined', () => { + const quoteResponse = { + quote: { + intent: undefined, + srcChainId: 1, + destChainId: 1, + }, + } as never; + + expect(() => getIntentFromQuote(quoteResponse)).toThrow( + 'submitIntent: missing intent data', + ); + }); + }); }); diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index d40c8346ac1..8b87cf848be 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -4,9 +4,7 @@ import { FeeType, formatChainIdToCaip, formatChainIdToHex, - StatusTypes, } from '@metamask/bridge-controller'; -import type { Quote } from '@metamask/bridge-controller'; import type { QuoteMetadata, QuoteResponse, @@ -18,102 +16,20 @@ import { } from '@metamask/transaction-controller'; import type { TransactionMeta } from '@metamask/transaction-controller'; -import { rekeyHistoryItemInState, getHistoryKey } from './history'; -import { getIntentFromQuote } from './intent-api'; +import { getStatusRequestParams } from './bridge-status'; import * as snaps from './snaps'; import { - getStatusRequestParams, handleApprovalDelay, handleMobileHardwareWalletDelay, - toBatchTxParams, getAddTransactionBatchParams, findAndUpdateTransactionsInBatch, waitForTxConfirmation, + toBatchTxParams, } from './transaction'; import { APPROVAL_DELAY_MS } from '../constants'; import type { BridgeStatusControllerMessenger } from '../types'; -import type { - BridgeStatusControllerState, - BridgeHistoryItem, - StatusResponse, -} from '../types'; describe('Bridge Status Controller Transaction Utils', () => { - describe('rekeyHistoryItemInState', () => { - const makeState = ( - overrides?: Partial, - ): BridgeStatusControllerState => - ({ - txHistory: {}, - ...overrides, - }) as BridgeStatusControllerState; - - it('returns false when history item missing', () => { - const state = makeState(); - const result = rekeyHistoryItemInState(state, 'missing', { - id: 'tx1', - hash: '0xhash', - }); - expect(result).toBe(false); - }); - - it('rekeys and preserves srcTxHash', () => { - const state = makeState({ - txHistory: { - action1: { - txMetaId: undefined, - actionId: 'action1', - originalTransactionId: undefined, - quote: { srcChainId: 1, destChainId: 10 } as Quote, - status: { - status: StatusTypes.SUBMITTED, - srcChain: { chainId: 1, txHash: '0xold' }, - } as StatusResponse, - account: '0xaccount', - estimatedProcessingTimeInSeconds: 1, - slippagePercentage: 0, - hasApprovalTx: false, - } as BridgeHistoryItem, - }, - }); - - const result = rekeyHistoryItemInState(state, 'action1', { - id: 'tx1', - hash: '0xnew', - }); - - expect(result).toBe(true); - expect(state.txHistory.action1).toBeUndefined(); - expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xnew'); - }); - - it('uses existing srcTxHash when txMeta hash is missing', () => { - const state = makeState({ - txHistory: { - action1: { - txMetaId: undefined, - actionId: 'action1', - originalTransactionId: undefined, - quote: { srcChainId: 1, destChainId: 10 } as Quote, - status: { - status: StatusTypes.SUBMITTED, - srcChain: { chainId: 1, txHash: '0xold' }, - } as StatusResponse, - account: '0xaccount', - estimatedProcessingTimeInSeconds: 1, - slippagePercentage: 0, - hasApprovalTx: false, - } as BridgeHistoryItem, - }, - }); - - const result = rekeyHistoryItemInState(state, 'action1', { id: 'tx1' }); - - expect(result).toBe(true); - expect(state.txHistory.tx1.status.srcChain.txHash).toBe('0xold'); - }); - }); - describe('waitForTxConfirmation', () => { it('resolves when confirmed', async () => { const messenger = { @@ -194,6 +110,7 @@ describe('Bridge Status Controller Transaction Utils', () => { jest.useRealTimers(); }); }); + describe('getStatusRequestParams', () => { it('should extract status request parameters from a quote response', () => { const mockQuoteResponse: QuoteResponse = { @@ -1850,7 +1767,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }) as never; const createMockMessagingSystem = ( - estimateGasFeeOverrides: Record = {}, + estimateGasFeeOverrides: Record = { estimates: {} }, ) => ({ call: jest.fn().mockImplementation((method: string) => { @@ -1882,11 +1799,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }; } if (method === 'TransactionController:estimateGasFee') { - return ( - estimateGasFeeOverrides ?? { - estimates: {}, - } - ); + return estimateGasFeeOverrides; } return undefined; }), @@ -2526,66 +2439,4 @@ describe('Bridge Status Controller Transaction Utils', () => { ); }); }); - - describe('getHistoryKey', () => { - it('returns actionId when both actionId and bridgeTxMetaId are provided', () => { - expect(getHistoryKey('action-123', 'tx-456')).toBe('action-123'); - }); - - it('returns bridgeTxMetaId when only bridgeTxMetaId is provided', () => { - expect(getHistoryKey(undefined, 'tx-456')).toBe('tx-456'); - }); - - it('returns actionId when only actionId is provided', () => { - expect(getHistoryKey('action-123', undefined)).toBe('action-123'); - }); - - it('throws error when neither actionId nor bridgeTxMetaId is provided', () => { - expect(() => getHistoryKey(undefined, undefined)).toThrow( - 'Cannot add tx to history: either actionId or bridgeTxMeta.id must be provided', - ); - }); - }); - - describe('getIntentFromQuote', () => { - it('returns intent when present in quote response', () => { - const mockIntent = { protocol: 'cowswap', order: { some: 'data' } }; - const quoteResponse = { - quote: { - intent: mockIntent, - srcChainId: 1, - destChainId: 1, - }, - } as never; - - expect(getIntentFromQuote(quoteResponse)).toBe(mockIntent); - }); - - it('throws error when intent is missing from quote', () => { - const quoteResponse = { - quote: { - srcChainId: 1, - destChainId: 1, - }, - } as never; - - expect(() => getIntentFromQuote(quoteResponse)).toThrow( - 'submitIntent: missing intent data', - ); - }); - - it('throws error when intent is undefined', () => { - const quoteResponse = { - quote: { - intent: undefined, - srcChainId: 1, - destChainId: 1, - }, - } as never; - - expect(() => getIntentFromQuote(quoteResponse)).toThrow( - 'submitIntent: missing intent data', - ); - }); - }); }); diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 764f5a41fe9..7aac28c57b2 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -1,5 +1,9 @@ /* eslint-disable @typescript-eslint/explicit-function-return-type */ -import { ChainId, formatChainIdToHex } from '@metamask/bridge-controller'; +import { + ChainId, + formatChainIdToHex, + BRIDGE_PREFERRED_GAS_ESTIMATE, +} from '@metamask/bridge-controller'; import type { QuoteMetadata, QuoteResponse, @@ -12,29 +16,140 @@ import { } from '@metamask/transaction-controller'; import type { BatchTransactionParams, + IsAtomicBatchSupportedResultEntry, TransactionController, TransactionMeta, + TransactionBatchSingleRequest, + TransactionParams, } from '@metamask/transaction-controller'; -import type { TransactionBatchSingleRequest } from '@metamask/transaction-controller'; -import { createProjectLogger } from '@metamask/utils'; +import { createProjectLogger, Hex } from '@metamask/utils'; import { getAccountByAddress } from './accounts'; -import { calculateGasFees } from './gas'; +import { calculateGasFees, getTxGasEstimates } from './gas'; import { getNetworkClientIdByChainId } from './network'; import { APPROVAL_DELAY_MS } from '../constants'; import type { BridgeStatusControllerMessenger } from '../types'; +export const getGasFeeEstimates = async ( + messenger: BridgeStatusControllerMessenger, + args: Parameters[0], +): Promise<{ maxFeePerGas?: string; maxPriorityFeePerGas?: string }> => { + const { estimates } = await messenger.call( + 'TransactionController:estimateGasFee', + args, + ); + if ( + BRIDGE_PREFERRED_GAS_ESTIMATE in estimates && + typeof estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] === 'object' && + 'maxFeePerGas' in estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] && + 'maxPriorityFeePerGas' in estimates[BRIDGE_PREFERRED_GAS_ESTIMATE] + ) { + return estimates[BRIDGE_PREFERRED_GAS_ESTIMATE]; + } + return {}; +}; + +export const getTransactions = (messenger: BridgeStatusControllerMessenger) => { + return messenger.call('TransactionController:getState').transactions ?? []; +}; + +export const getTransactionMetaById = ( + messenger: BridgeStatusControllerMessenger, + txId?: string, +) => { + return getTransactions(messenger).find( + (tx: TransactionMeta) => tx.id === txId, + ); +}; + +export const getTransactionMetaByHash = ( + messenger: BridgeStatusControllerMessenger, + txHash?: string, +) => { + return getTransactions(messenger).find( + (tx: TransactionMeta) => tx.hash === txHash, + ); +}; + +export const updateTransaction = ( + messenger: BridgeStatusControllerMessenger, + txMeta: TransactionMeta, + txMetaUpdates: Partial, + note: string, +) => { + return messenger.call( + 'TransactionController:updateTransaction', + { ...txMeta, ...txMetaUpdates }, + note, + ); +}; + +export const checkIsDelegatedAccount = async ( + messenger: BridgeStatusControllerMessenger, + fromAddress: Hex, + chainIds: Hex[], +): Promise => { + try { + const atomicBatchSupport = await messenger.call( + 'TransactionController:isAtomicBatchSupported', + { + address: fromAddress, + chainIds, + }, + ); + return atomicBatchSupport.some( + (entry: IsAtomicBatchSupportedResultEntry) => + entry.isSupported && entry.delegationAddress, + ); + } catch { + return false; + } +}; + +const waitForHashAndReturnFinalTxMeta = async ( + messenger: BridgeStatusControllerMessenger, + hashPromise?: Awaited< + ReturnType + >['result'], +): Promise => { + const txHash = await hashPromise; + const finalTransactionMeta = getTransactionMetaByHash(messenger, txHash); + if (!finalTransactionMeta) { + throw new Error( + 'Failed to submit cross-chain swap tx: txMeta for txHash was not found', + ); + } + return finalTransactionMeta; +}; + +export const addTransaction = async ( + messenger: BridgeStatusControllerMessenger, + ...args: Parameters +) => { + const { result } = await messenger.call( + 'TransactionController:addTransaction', + ...args, + ); + return await waitForHashAndReturnFinalTxMeta(messenger, result); +}; + export const generateActionId = () => (Date.now() + Math.random()).toString(); -export const getStatusRequestParams = (quoteResponse: QuoteResponse) => { - return { - bridgeId: quoteResponse.quote.bridgeId, - bridge: quoteResponse.quote.bridges[0], - srcChainId: quoteResponse.quote.srcChainId, - destChainId: quoteResponse.quote.destChainId, - quote: quoteResponse.quote, - refuel: Boolean(quoteResponse.quote.refuel), - }; +export const addSyntheticTransaction = async ( + messenger: BridgeStatusControllerMessenger, + ...args: Parameters +) => { + const { transactionMeta } = await messenger.call( + 'TransactionController:addTransaction', + args[0], + { + origin: 'metamask', + actionId: generateActionId(), + isStateOnly: true, + ...args[1], + }, + ); + return transactionMeta; }; export const handleApprovalDelay = async ( @@ -70,6 +185,17 @@ export const handleMobileHardwareWalletDelay = async ( } }; +/** + * Waits until a given transaction (by id) reaches confirmed/finalized status or fails/times out. + * + * @deprecated use addTransaction util + * @param messenger - the BridgeStatusControllerMessenger + * @param txId - the transaction ID + * @param options - the options for the timeout and poll + * @param options.timeoutMs - the timeout in milliseconds + * @param options.pollMs - the poll interval in milliseconds + * @returns the transaction meta + */ export const waitForTxConfirmation = async ( messenger: BridgeStatusControllerMessenger, txId: string, @@ -80,8 +206,7 @@ export const waitForTxConfirmation = async ( ): Promise => { const start = Date.now(); while (true) { - const { transactions } = messenger.call('TransactionController:getState'); - const meta = transactions.find((tx: TransactionMeta) => tx.id === txId); + const meta = getTransactionMetaById(messenger, txId); if (meta) { if (meta.status === TransactionStatus.confirmed) { @@ -115,9 +240,9 @@ export const toBatchTxParams = ( ): BatchTransactionParams => { const params = { ...trade, - data: trade.data as `0x${string}`, - to: trade.to as `0x${string}`, - value: trade.value as `0x${string}`, + data: trade.data as `0x`, + to: trade.to as `0x`, + value: trade.value as `0x`, }; if (skipGasFields) { return params; @@ -160,6 +285,7 @@ export const getAddTransactionBatchParams = async ({ requireApproval?: boolean; isDelegatedAccount?: boolean; }) => { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing const isGasless = gasIncluded || gasIncluded7702; const selectedAccount = getAccountByAddress(messenger, trade.from); if (!selectedAccount) { @@ -234,7 +360,7 @@ export const getAddTransactionBatchParams = async ({ networkClientId, requireApproval, origin: 'metamask', - from: trade.from as `0x${string}`, + from: trade.from as `0x`, transactions, }; @@ -250,7 +376,7 @@ export const findAndUpdateTransactionsInBatch = ({ batchId: string; txDataByType: { [key in TransactionType]?: string }; }) => { - const txs = messenger.call('TransactionController:getState').transactions; + const txs = getTransactions(messenger); const txBatch: { approvalMeta?: TransactionMeta; tradeMeta?: TransactionMeta; @@ -261,7 +387,8 @@ export const findAndUpdateTransactionsInBatch = ({ // This is a workaround to update the tx type after the tx is signed // TODO: remove this once the tx type for batch txs is preserved in the tx controller - Object.entries(txDataByType).forEach(([txType, txData]) => { + const txEntries = Object.entries(txDataByType) as [TransactionType, string][]; + txEntries.forEach(([txType, txData]) => { // Skip types not present in the batch (e.g. swap entry is undefined for bridge txs) if (txData === undefined) { return; @@ -305,21 +432,176 @@ export const findAndUpdateTransactionsInBatch = ({ }); if (txMeta) { - const updatedTx = { ...txMeta, type: txType as TransactionType }; - messenger.call( - 'TransactionController:updateTransaction', - updatedTx, + const updatedTx = { ...txMeta, type: txType }; + updateTransaction( + messenger, + txMeta, + { type: txType }, `Update tx type to ${txType}`, ); - txBatch[ - [TransactionType.bridgeApproval, TransactionType.swapApproval].includes( - txType as TransactionType, - ) - ? 'approvalMeta' - : 'tradeMeta' - ] = updatedTx; + const txTypes = [ + TransactionType.bridgeApproval, + TransactionType.swapApproval, + ] as readonly string[]; + txBatch[txTypes.includes(txType) ? 'approvalMeta' : 'tradeMeta'] = + updatedTx; } }); return txBatch; }; + +export const addTransactionBatch = async ( + messenger: BridgeStatusControllerMessenger, + addTransactionBatchFn: TransactionController['addTransactionBatch'], + ...args: Parameters +) => { + const txDataByType = { + [TransactionType.bridgeApproval]: args[0].transactions.find( + ({ type }) => type === TransactionType.bridgeApproval, + )?.params.data, + [TransactionType.swapApproval]: args[0].transactions.find( + ({ type }) => type === TransactionType.swapApproval, + )?.params.data, + [TransactionType.bridge]: args[0].transactions.find( + ({ type }) => type === TransactionType.bridge, + )?.params.data, + [TransactionType.swap]: args[0].transactions.find( + ({ type }) => type === TransactionType.swap, + )?.params.data, + }; + + const { batchId } = await addTransactionBatchFn(...args); + + const { approvalMeta, tradeMeta } = findAndUpdateTransactionsInBatch({ + messenger, + batchId, + txDataByType, + }); + + if (!tradeMeta) { + throw new Error( + 'Failed to update cross-chain swap transaction batch: tradeMeta not found', + ); + } + + return { approvalMeta, tradeMeta }; +}; + +// TODO rename +const getGasFeesForSubmission = async ( + messenger: BridgeStatusControllerMessenger, + transactionParams: TransactionParams, + networkClientId: string, + chainId: Hex, + txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }, +): Promise<{ + maxFeePerGas?: string; // Hex + maxPriorityFeePerGas?: string; // Hex + gas?: Hex; +}> => { + const { gas } = transactionParams; + // If txFee is provided (gasIncluded case), use the quote's gas fees + // Convert to hex since txFee values from the quote are decimal strings + if (txFee) { + return { + maxFeePerGas: toHex(txFee.maxFeePerGas), + maxPriorityFeePerGas: toHex(txFee.maxPriorityFeePerGas), + gas: gas ? toHex(gas) : undefined, + }; + } + + const { maxFeePerGas, maxPriorityFeePerGas } = await getTxGasEstimates( + messenger, + { + transactionParams, + chainId, + networkClientId, + }, + ); + + return { + maxFeePerGas, + maxPriorityFeePerGas, + gas: gas ? toHex(gas) : undefined, + }; +}; + +/** + * Submits an EVM transaction to the TransactionController + * + * @param params - The parameters for the transaction + * @param params.transactionType - The type of transaction to submit + * @param params.trade - The trade data to confirm + * @param params.requireApproval - Whether to require approval for the transaction + * @param params.txFee - Optional gas fee parameters from the quote (used when gasIncluded is true) + * @param params.txFee.maxFeePerGas - The maximum fee per gas from the quote + * @param params.txFee.maxPriorityFeePerGas - The maximum priority fee per gas from the quote + * @param params.actionId - Optional actionId for pre-submission history (if not provided, one is generated) + * @param params.messenger - The messenger to use for the transaction + * @returns The transaction meta + */ +export const submitEvmTransaction = async ({ + messenger, + trade, + transactionType, + requireApproval = false, + txFee, + // Use provided actionId (for pre-submission history) or generate one + actionId = generateActionId(), +}: { + messenger: BridgeStatusControllerMessenger; + transactionType: TransactionType; + trade: TxData; + requireApproval?: boolean; + txFee?: { maxFeePerGas: string; maxPriorityFeePerGas: string }; + actionId?: string; +}): Promise => { + const selectedAccount = getAccountByAddress(messenger, trade.from); + if (!selectedAccount) { + throw new Error( + 'Failed to submit cross-chain swap transaction: unknown account in trade data', + ); + } + const hexChainId = formatChainIdToHex(trade.chainId); + const networkClientId = getNetworkClientIdByChainId(messenger, hexChainId); + + const requestOptions = { + actionId, + networkClientId, + requireApproval, + type: transactionType, + origin: 'metamask', + }; + // Exclude gasLimit from trade to avoid type issues (it can be null) + const { gasLimit: tradeGasLimit, ...tradeWithoutGasLimit } = trade; + + const transactionParams: Parameters< + TransactionController['addTransaction'] + >[0] = { + ...tradeWithoutGasLimit, + chainId: hexChainId, + // Only add gasLimit and gas if they're valid (not undefined/null/zero) + ...(tradeGasLimit && + tradeGasLimit !== 0 && { + gasLimit: tradeGasLimit.toString(), + gas: tradeGasLimit.toString(), + }), + }; + const transactionParamsWithMaxGas: TransactionParams = { + ...transactionParams, + ...(await getGasFeesForSubmission( + messenger, + transactionParams, + networkClientId, + hexChainId, + txFee, + )), + }; + + return await addTransaction( + messenger, + transactionParamsWithMaxGas, + requestOptions, + ); +}; From 3ea4017af491c69b41e0ea7f0b3a5e9bdfd21fd6 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 16:26:51 -0700 Subject: [PATCH 07/12] refactor: extract intent transaction utils and update hash type --- ...e-status-controller.intent-manager.test.ts | 35 +++++++++++------ .../src/bridge-status-controller.intent.ts | 39 +++++++++---------- .../src/utils/intent-api.test.ts | 6 +-- .../src/utils/intent-api.ts | 2 +- 4 files changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts index f310d3d1592..7119ffc84a9 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts @@ -259,7 +259,7 @@ describe('IntentManager', () => { expect(result?.bridgeStatus).toBeDefined(); }); - it('getIntentTransactionStatus passes empty txHash fallback when srcChain is missing', async () => { + it('getIntentTransactionStatus passes undefined txHash when srcChain is missing', async () => { const order = { id: 'order-1', status: IntentOrderStatus.SUBMITTED, @@ -286,7 +286,7 @@ describe('IntentManager', () => { ); expect(result).toBeDefined(); - expect(result?.bridgeStatus?.status.srcChain.txHash).toBe(''); + expect(result?.bridgeStatus?.status.srcChain.txHash).toBeUndefined(); }); it('syncTransactionFromIntentStatus cleans up intent statuses map when order is complete', async () => { @@ -298,7 +298,8 @@ describe('IntentManager', () => { const completedOrder = { id: 'order-3', status: IntentOrderStatus.COMPLETED, - txHash: '0xhash', + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', metadata: {}, }; const mockCall = jest.fn((...args: unknown[]) => { @@ -321,7 +322,11 @@ describe('IntentManager', () => { originalTransactionId: 'tx-2', status: { status: StatusTypes.PENDING, - srcChain: { chainId: 1, txHash: '0xhash' }, + srcChain: { + chainId: 1, + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', + }, }, }); await manager.getIntentTransactionStatus( @@ -348,12 +353,12 @@ describe('IntentManager', () => { [ "TransactionController:updateTransaction", { - "hash": "0xhash", + "hash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", "id": "tx-2", "status": "confirmed", "txReceipt": { "status": "0x1", - "transactionHash": "0xhash", + "transactionHash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", }, }, "BridgeStatusController - Intent order status updated: completed", @@ -371,7 +376,8 @@ describe('IntentManager', () => { const failedOrder = { id: 'order-3', status: IntentOrderStatus.FAILED, - txHash: '0xhash', + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', metadata: {}, }; const mockCall = jest.fn((...args: unknown[]) => { @@ -421,12 +427,12 @@ describe('IntentManager', () => { [ "TransactionController:updateTransaction", { - "hash": "0xhash", + "hash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", "id": "tx-2", "status": "failed", "txReceipt": { "status": "0x0", - "transactionHash": "0xhash", + "transactionHash": "0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc", }, }, "BridgeStatusController - Intent order status updated: failed", @@ -480,7 +486,8 @@ describe('IntentManager', () => { const submittedOrder = { id: 'order-3', status: IntentOrderStatus.SUBMITTED, - txHash: '0xhash', + txHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', metadata: {}, }; const mockCall = jest.fn((...args: unknown[]) => { @@ -488,6 +495,9 @@ describe('IntentManager', () => { if (method === 'TransactionController:updateTransaction') { return { transactions: [existingTxMeta] }; } + if (method === 'AuthenticationController:getBearerToken') { + return 'token'; + } return { transactions: [existingTxMeta] }; }); const manager = new IntentManager( @@ -520,7 +530,8 @@ describe('IntentManager', () => { expect.objectContaining({ id: 'tx-2', txReceipt: expect.objectContaining({ - transactionHash: '0xhash', + transactionHash: + '0xb756e7c856f1bf6ca3c3feda2067b85574383fb1f4ce95b175c2d447c932cdcc', status: '0x0', }), }), @@ -581,7 +592,7 @@ describe('IntentManager', () => { [ "TransactionController:updateTransaction", { - "hash": undefined, + "hash": "", "id": "tx-2", "status": "submitted", }, diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts index 76affbd6b8f..af58e2cbc7c 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts @@ -1,5 +1,4 @@ import { BridgeClientId, StatusTypes } from '@metamask/bridge-controller'; -import type { TransactionMeta } from '@metamask/transaction-controller'; import type { BridgeStatusControllerMessenger, FetchFunction } from './types'; import type { BridgeHistoryItem } from './types'; @@ -11,6 +10,7 @@ import { IntentSubmissionParams, translateIntentOrderToBridgeStatus, } from './utils/intent-api'; +import { getTransactionMetaById, updateTransaction } from './utils/transaction'; import { IntentStatusResponse, IntentOrderStatus } from './utils/validators'; type IntentStatuses = { @@ -57,7 +57,7 @@ export class IntentManager { bridgeTxMetaId: string, order: IntentStatusResponse, srcChainId: number, - txHash: string, + txHash?: string, ): IntentStatuses { const bridgeStatus = translateIntentOrderToBridgeStatus( order, @@ -86,7 +86,7 @@ export class IntentManager { srcChainId: number, protocol: string, clientId: BridgeClientId, - txHash: string = '', + txHash?: string, ): Promise => { try { const orderStatus = await this.intentApi.getOrderStatus( @@ -100,7 +100,7 @@ export class IntentManager { bridgeTxMetaId, orderStatus, srcChainId, - txHash.toString(), + txHash, ); } catch (error: unknown) { if (error instanceof Error) { @@ -139,11 +139,9 @@ export class IntentManager { try { // Merge with existing TransactionMeta to avoid wiping required fields - const { transactions } = this.#messenger.call( - 'TransactionController:getState', - ); - const existingTxMeta = transactions.find( - (tx: TransactionMeta) => tx.id === originalTxId, + const existingTxMeta = getTransactionMetaById( + this.#messenger, + originalTxId, ); if (!existingTxMeta) { console.warn( @@ -154,6 +152,7 @@ export class IntentManager { } const { bridgeStatus, orderStatus } = intentStatuses; const txHash = bridgeStatus?.txHash; + const isComplete = bridgeStatus?.status.status === StatusTypes.COMPLETE; const isFinalStatus = bridgeStatus?.status.status === StatusTypes.COMPLETE || @@ -165,22 +164,20 @@ export class IntentManager { ? { txReceipt: { ...existingTxReceipt, - transactionHash: txHash, - status: (isComplete ? '0x1' : '0x0') as unknown as string, + transactionHash: txHash as `0x${string}` | undefined, + status: isComplete ? '0x1' : '0x0', }, } : {}; - const updatedTxMeta: TransactionMeta = { - ...existingTxMeta, - status: bridgeStatus?.transactionStatus, - ...(txHash ? { hash: txHash } : {}), - ...txReceiptUpdate, - } as TransactionMeta; - - this.#messenger.call( - 'TransactionController:updateTransaction', - updatedTxMeta, + updateTransaction( + this.#messenger, + existingTxMeta, + { + status: bridgeStatus?.transactionStatus, + hash: txHash, + ...txReceiptUpdate, + }, `BridgeStatusController - Intent order status updated: ${orderStatus}`, ); diff --git a/packages/bridge-status-controller/src/utils/intent-api.test.ts b/packages/bridge-status-controller/src/utils/intent-api.test.ts index ef34cc10453..4c39778a939 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.test.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.test.ts @@ -244,7 +244,7 @@ describe('IntentApiImpl', () => { expect(translation.status.srcChain.txHash).toBe('0xfallback'); }); - it('returns empty txHash when neither intentOrder.txHash nor fallback exist', () => { + it('returns undefined txHash when neither intentOrder.txHash nor fallback exist', () => { const translation = translateIntentOrderToBridgeStatus( { id: 'order-nohash', @@ -254,8 +254,8 @@ describe('IntentApiImpl', () => { 1, ); - expect(translation.txHash).toBe(''); - expect(translation.status.srcChain.txHash).toBe(''); + expect(translation.txHash).toBeUndefined(); + expect(translation.status.srcChain.txHash).toBeUndefined(); }); it('maps confirmed intent to COMPLETE status', () => { diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index a6f79d42a17..8f138406883 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -137,7 +137,7 @@ export const translateIntentOrderToBridgeStatus = ( statusType = StatusTypes.UNKNOWN; } - const txHash = intentOrder.txHash ?? fallbackTxHash ?? ''; + const txHash = intentOrder.txHash ?? fallbackTxHash; const status: StatusResponse = { status: statusType, srcChain: { From 0fe145dde0e95049cdeba81ec40c409b7964941c Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 12 Mar 2026 18:41:41 -0700 Subject: [PATCH 08/12] fix: intent startTime --- .../src/bridge-status-controller.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index a043fea4077..de7de8b2d8d 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1246,6 +1246,8 @@ export class BridgeStatusController extends StaticIntervalPollingController Date: Mon, 16 Mar 2026 16:51:00 -0700 Subject: [PATCH 09/12] chore: remove submitIntent from IntentManager --- ...e-status-controller.intent-manager.test.ts | 14 +++- .../bridge-status-controller.intent.test.ts | 56 ++++++++++------ .../src/bridge-status-controller.intent.ts | 15 ----- .../src/bridge-status-controller.ts | 12 ++-- .../src/utils/intent-api.test.ts | 45 ++++++++++--- .../src/utils/intent-api.ts | 66 ++++++++++--------- 6 files changed, 125 insertions(+), 83 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts index 7119ffc84a9..4df107aa67a 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent-manager.test.ts @@ -4,6 +4,7 @@ import { TransactionStatus } from '@metamask/transaction-controller'; import { IntentManager } from './bridge-status-controller.intent'; import type { BridgeHistoryItem } from './types'; +import { postSubmitOrder } from './utils/intent-api'; import { IntentOrderStatus } from './utils/validators'; const makeHistoryItem = ( @@ -663,8 +664,9 @@ describe('IntentManager', () => { status: IntentOrderStatus.SUBMITTED, metadata: {}, }; - const fetchFn = jest.fn().mockResolvedValue(expectedOrder); - const manager = new IntentManager(createManagerOptions({ fetchFn })); + const { customBridgeApiBaseUrl, fetchFn } = createManagerOptions({ + fetchFn: jest.fn().mockResolvedValue(expectedOrder), + }); const params = { srcChainId: 1, @@ -675,7 +677,13 @@ describe('IntentManager', () => { aggregatorId: 'cowswap', }; - const result = await manager.submitIntent(params, BridgeClientId.EXTENSION); + const result = await postSubmitOrder({ + params, + clientId: BridgeClientId.EXTENSION, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: customBridgeApiBaseUrl, + }); expect(result).toStrictEqual(expectedOrder); }); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts index ce7a81349cb..904aee21324 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.test.ts @@ -19,9 +19,7 @@ import * as historyUtils from './utils/history'; import * as intentApi from './utils/intent-api'; import { IntentOrderStatus } from './utils/validators'; -jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') - .mockImplementation(jest.fn()); +jest.spyOn(intentApi, 'postSubmitOrder').mockImplementation(jest.fn()); jest .spyOn(intentApi.IntentApiImpl.prototype, 'getOrderStatus') .mockImplementation(jest.fn()); @@ -211,6 +209,9 @@ const createMessengerHarness = ( transactionMeta: intentTx, }; } + case 'AuthenticationController:getBearerToken': { + return '0xjwt'; + } case 'NetworkController:findNetworkClientIdByChainId': return 'network-client-id-1'; case 'NetworkController:getState': @@ -298,7 +299,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; const submitIntentSpy = jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse({ @@ -346,7 +347,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; const submitIntentSpy = jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse({ @@ -406,7 +407,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; const submitIntentSpy = jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse({ @@ -443,7 +444,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); jest.spyOn(historyUtils, 'getInitialHistoryItem').mockImplementation(() => { @@ -481,7 +482,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; const submitIntentSpy = jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse(); @@ -522,7 +523,24 @@ describe('BridgeStatusController (intent swaps)', () => { ]), ); - expect(submitIntentSpy.mock.calls[0]?.[0]?.signature).toBe('0xautosigned'); + expect(submitIntentSpy.mock.calls[0]?.[0]).toMatchInlineSnapshot(` + { + "bridgeApiBaseUrl": "http://localhost", + "clientId": "extension", + "fetchFn": [Function], + "jwt": "0xjwt", + "params": { + "aggregatorId": "cowswap", + "order": { + "some": "order", + }, + "quoteId": "req-1", + "signature": "0xautosigned", + "srcChainId": 1, + "userAddress": "0xAccount1", + }, + } + `); }); it('intent polling: updates history, merges tx hashes, updates TC tx, and stops polling on COMPLETED', async () => { @@ -537,7 +555,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse(); @@ -581,7 +599,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse(); @@ -628,7 +646,7 @@ describe('BridgeStatusController (intent swaps)', () => { metadata: { txHashes: [] }, }; jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') + .spyOn(intentApi, 'postSubmitOrder') .mockResolvedValue(intentStatusResponse); const quoteResponse = minimalIntentQuoteResponse(); @@ -683,14 +701,12 @@ describe('BridgeStatusController (intent swaps)', () => { }, }); - jest - .spyOn(intentApi.IntentApiImpl.prototype, 'submitIntent') - .mockResolvedValue({ - id: orderUid, - status: IntentOrderStatus.SUBMITTED, - txHash: undefined, - metadata: { txHashes: [] }, - }); + jest.spyOn(intentApi, 'postSubmitOrder').mockResolvedValue({ + id: orderUid, + status: IntentOrderStatus.SUBMITTED, + txHash: undefined, + metadata: { txHashes: [] }, + }); const historyKey = orderUid; diff --git a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts index af58e2cbc7c..8d5395468f4 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.intent.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.intent.ts @@ -7,7 +7,6 @@ import { IntentApi, IntentApiImpl, IntentBridgeStatus, - IntentSubmissionParams, translateIntentOrderToBridgeStatus, } from './utils/intent-api'; import { getTransactionMetaById, updateTransaction } from './utils/transaction'; @@ -192,18 +191,4 @@ export class IntentManager { }); } }; - - /** - * Submit an intent order. - * - * @param submissionParams - The submission parameters. - * @param clientId - The client ID. - * @returns The intent order. - */ - submitIntent = async ( - submissionParams: IntentSubmissionParams, - clientId: BridgeClientId, - ): Promise => { - return this.intentApi.submitIntent(submissionParams, clientId); - }; } diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index de7de8b2d8d..4e2c3e48223 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -67,6 +67,7 @@ import { getIntentFromQuote, IntentSubmissionParams, mapIntentOrderStatusToTransactionStatus, + postSubmitOrder, } from './utils/intent-api'; import { signTypedMessage } from './utils/keyring'; import { @@ -1306,10 +1307,13 @@ export class BridgeStatusController extends StaticIntervalPollingController { const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); const params = makeParams(); - const result = await api.submitIntent(params, clientId); + const result = await postSubmitOrder({ + params, + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }); expect(result).toStrictEqual(validIntentOrderResponse); expect(fetchFn).toHaveBeenCalledTimes(1); @@ -60,18 +67,30 @@ describe('IntentApiImpl', () => { const fetchFn = makeFetchMock().mockRejectedValue(new Error('boom')); const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( - 'Failed to submit intent: boom', - ); + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent: boom'); }); it('submitIntent throws generic error when rejection is not an Error', async () => { const fetchFn = makeFetchMock().mockRejectedValue('boom'); const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( - 'Failed to submit intent', - ); + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent'); }); it('getOrderStatus calls GET /getOrderStatus with encoded query params and returns response', async () => { @@ -131,9 +150,15 @@ describe('IntentApiImpl', () => { const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect(api.submitIntent(makeParams(), clientId)).rejects.toThrow( - 'Failed to submit intent: Invalid submitOrder response', - ); + await expect( + postSubmitOrder({ + params: makeParams(), + clientId, + jwt: undefined, + fetchFn, + bridgeApiBaseUrl: baseUrl, + }), + ).rejects.toThrow('Failed to submit intent: Invalid submitOrder response'); }); it('getOrderStatus throws when response fails validation', async () => { diff --git a/packages/bridge-status-controller/src/utils/intent-api.ts b/packages/bridge-status-controller/src/utils/intent-api.ts index 8f138406883..ce682921142 100644 --- a/packages/bridge-status-controller/src/utils/intent-api.ts +++ b/packages/bridge-status-controller/src/utils/intent-api.ts @@ -25,10 +25,6 @@ export type IntentSubmissionParams = { }; export type IntentApi = { - submitIntent( - params: IntentSubmissionParams, - clientId: BridgeClientId, - ): Promise; getOrderStatus( orderId: string, aggregatorId: string, @@ -52,33 +48,6 @@ export class IntentApiImpl implements IntentApi { this.#getJwt = getJwt; } - async submitIntent( - params: IntentSubmissionParams, - clientId: BridgeClientId, - ): Promise { - const endpoint = `${this.#baseUrl}/submitOrder`; - try { - const jwt = await this.#getJwt(); - const response = await this.#fetchFn(endpoint, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...getClientHeaders({ clientId, jwt }), - }, - body: JSON.stringify(params), - }); - if (!validateIntentStatusResponse(response)) { - throw new Error('Invalid submitOrder response'); - } - return response; - } catch (error: unknown) { - if (error instanceof Error) { - throw new Error(`Failed to submit intent: ${error.message}`); - } - throw new Error('Failed to submit intent'); - } - } - async getOrderStatus( orderId: string, aggregatorId: string, @@ -188,3 +157,38 @@ export function getIntentFromQuote(quoteResponse: QuoteResponse): Intent { } return intent; } + +export const postSubmitOrder = async ({ + params, + clientId, + jwt, + fetchFn, + bridgeApiBaseUrl, +}: { + params: IntentSubmissionParams; + clientId: BridgeClientId; + jwt: string | undefined; + fetchFn: FetchFunction; + bridgeApiBaseUrl: string; +}): Promise => { + const endpoint = `${bridgeApiBaseUrl}/submitOrder`; + try { + const response = await fetchFn(endpoint, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ clientId, jwt }), + }, + body: JSON.stringify(params), + }); + if (!validateIntentStatusResponse(response)) { + throw new Error('Invalid submitOrder response'); + } + return response; + } catch (error: unknown) { + if (error instanceof Error) { + throw new Error(`Failed to submit intent: ${error.message}`); + } + throw new Error('Failed to submit intent'); + } +}; From 72a6c002741bfd2b5a192535fea00daa453aed57 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Wed, 18 Mar 2026 17:27:38 -0700 Subject: [PATCH 10/12] chore: add actionId and originalTransactionId to polling args history --- .../src/bridge-status-controller.ts | 24 +++++++-------- .../bridge-status-controller/src/types.ts | 10 ++++++- .../src/utils/history.ts | 30 +++++++++++-------- 3 files changed, 38 insertions(+), 26 deletions(-) diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 4e2c3e48223..7f70c6afd56 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1142,20 +1142,18 @@ export class BridgeStatusController extends StaticIntervalPollingController; + actionId?: string; + /** + * @deprecated the txMeta or orderUid should be used instead + */ + originalTransactionId?: string; quoteResponse: QuoteResponse & QuoteMetadata; startTime?: BridgeHistoryItem['startTime']; slippagePercentage: BridgeHistoryItem['slippagePercentage']; diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index a51438e1942..00561dee59b 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -42,14 +42,16 @@ export const rekeyHistoryItemInState = ( * * @param actionId - The action ID used for pre-submission tracking * @param bridgeTxMetaId - The transaction meta ID from bridgeTxMeta + * @param syntheticTransactionId - The transactionId of the intent's placeholder transaction * @returns The key to use for the history item * @throws Error if neither actionId nor bridgeTxMetaId is provided */ export function getHistoryKey( actionId: string | undefined, bridgeTxMetaId: string | undefined, + syntheticTransactionId?: string, ): string { - const historyKey = actionId ?? bridgeTxMetaId; + const historyKey = actionId ?? bridgeTxMetaId ?? syntheticTransactionId; if (!historyKey) { throw new Error( 'Cannot add tx to history: either actionId or bridgeTxMeta.id must be provided', @@ -59,7 +61,12 @@ export function getHistoryKey( } export const getInitialHistoryItem = ( - { + args: StartPollingForBridgeTxStatusArgsSerialized, +): { + historyKey: string; + txHistoryItem: BridgeHistoryItem; +} => { + const { bridgeTxMeta, quoteResponse, startTime, @@ -72,25 +79,24 @@ export const getInitialHistoryItem = ( abTests, activeAbTests, accountAddress: selectedAddress, - }: StartPollingForBridgeTxStatusArgsSerialized, - actionId?: string, -): { - historyKey: string; - txHistoryItem: BridgeHistoryItem; -} => { + originalTransactionId, + actionId, + } = args; // Determine the key for this history item: // - For pre-submission (non-batch EVM): use actionId // - For post-submission or other cases: use bridgeTxMeta.id - const historyKey = getHistoryKey(actionId, bridgeTxMeta?.id); + const historyKey = getHistoryKey( + actionId, + bridgeTxMeta?.id, + originalTransactionId, + ); // Write all non-status fields to state so we can reference the quote in Activity list without the Bridge API // We know it's in progress but not the exact status yet const txHistoryItem = { txMetaId: bridgeTxMeta?.id, actionId, - originalTransactionId: - (bridgeTxMeta as unknown as { originalTransactionId: string }) - ?.originalTransactionId || bridgeTxMeta?.id, // Keep original for intent transactions + originalTransactionId: originalTransactionId ?? bridgeTxMeta?.id, // Keep original for intent transactions batchId: bridgeTxMeta?.batchId, quote: quoteResponse.quote, startTime, From f0caee89af3752c1deb2d0bad929ca6cd00cd690 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 19 Mar 2026 17:02:22 -0700 Subject: [PATCH 11/12] fix: lint errors --- eslint-suppressions.json | 5 ----- .../src/utils/metrics/properties.ts | 12 ++++++------ .../src/bridge-status-controller.test.ts | 10 ---------- .../src/bridge-status-controller.ts | 3 +-- .../src/utils/intent-api.test.ts | 7 ------- .../src/utils/snaps.test.ts | 1 + .../src/utils/snaps.ts | 19 +------------------ 7 files changed, 9 insertions(+), 48 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index be412fc9d8d..5e6209d9d8a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -609,11 +609,6 @@ "count": 3 } }, - "packages/bridge-status-controller/src/utils/transaction.ts": { - "@typescript-eslint/prefer-nullish-coalescing": { - "count": 1 - } - }, "packages/chain-agnostic-permission/src/caip25Permission.ts": { "@typescript-eslint/explicit-function-return-type": { "count": 11 diff --git a/packages/bridge-controller/src/utils/metrics/properties.ts b/packages/bridge-controller/src/utils/metrics/properties.ts index b66ec12e498..8f3ee9afe4d 100644 --- a/packages/bridge-controller/src/utils/metrics/properties.ts +++ b/packages/bridge-controller/src/utils/metrics/properties.ts @@ -22,12 +22,6 @@ import { formatChainIdToCaip, } from '../caip-formatters'; -export const isHardwareWallet = ( - selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], -) => { - return selectedAccount?.metadata?.keyring.type?.includes('Hardware') ?? false; -}; - export const toInputChangedPropertyKey: Partial< Record > = { @@ -112,6 +106,12 @@ export const getRequestParams = ({ }; }; +export const isHardwareWallet = ( + selectedAccount?: AccountsControllerState['internalAccounts']['accounts'][string], +) => { + return selectedAccount?.metadata?.keyring.type?.includes('Hardware') ?? false; +}; + /** * @param slippage - The slippage percentage * @returns Whether the default slippage was overridden by the user 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 8fcc2465c7d..fcfb2019a0b 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -4377,11 +4377,6 @@ describe('BridgeStatusController', () => { describe('TransactionController:transactionFailed', () => { it('should track failed event for bridge transaction', () => { const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - // messengerCallSpy.mockReturnValue({ - // transactions: [ - // { id: 'bridgeTxMetaId1', status: TransactionStatus.failed }, - // ], - // }); mockMessenger.publish('TransactionController:transactionFailed', { error: 'tx-error', transactionMeta: { @@ -4607,11 +4602,6 @@ describe('BridgeStatusController', () => { const unknownTxMetaId = 'unknown-tx-meta-id'; const messengerCallSpy = jest.spyOn(mockBridgeStatusMessenger, 'call'); - // messengerCallSpy.mockReturnValue({ - // transactions: [{ actionId, status: TransactionStatus.failed }], - // }); - - // Publish failure with an unknown txMeta.id but with matching actionId mockMessenger.publish('TransactionController:transactionFailed', { error: 'tx-error', transactionMeta: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 7f70c6afd56..477480cfb44 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -1269,7 +1269,6 @@ export class BridgeStatusController extends StaticIntervalPollingController { it('submitIntent calls POST /submitOrder with JSON body and returns response', async () => { const fetchFn = makeFetchMock().mockResolvedValue(validIntentOrderResponse); - const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); const params = makeParams(); const result = await postSubmitOrder({ @@ -65,8 +64,6 @@ describe('IntentApiImpl', () => { it('submitIntent rethrows Errors with a prefixed message', async () => { const fetchFn = makeFetchMock().mockRejectedValue(new Error('boom')); - const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect( postSubmitOrder({ params: makeParams(), @@ -80,8 +77,6 @@ describe('IntentApiImpl', () => { it('submitIntent throws generic error when rejection is not an Error', async () => { const fetchFn = makeFetchMock().mockRejectedValue('boom'); - const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect( postSubmitOrder({ params: makeParams(), @@ -148,8 +143,6 @@ describe('IntentApiImpl', () => { foo: 'bar', // invalid IntentOrder shape } as any); - const api = new IntentApiImpl(baseUrl, fetchFn, makeGetJwtMock()); - await expect( postSubmitOrder({ params: makeParams(), diff --git a/packages/bridge-status-controller/src/utils/snaps.test.ts b/packages/bridge-status-controller/src/utils/snaps.test.ts index f734b95355d..484756a4fe8 100644 --- a/packages/bridge-status-controller/src/utils/snaps.test.ts +++ b/packages/bridge-status-controller/src/utils/snaps.test.ts @@ -1,3 +1,4 @@ +/* eslint-disable consistent-return */ import { v4 as uuid } from 'uuid'; import { createClientTransactionRequest, handleNonEvmTx } from './snaps'; diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts index 9901ee2fefa..45fd83cd2aa 100644 --- a/packages/bridge-status-controller/src/utils/snaps.ts +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -1,3 +1,4 @@ +/* eslint-disable @typescript-eslint/explicit-function-return-type */ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { QuoteMetadata, @@ -98,24 +99,6 @@ export const getClientRequest = ( accountId, options, ); - // return { - // // @ts-expect-error - TODO snaps-controller does not export SnapId type (a string) - // snapId, - // origin: 'metamask', - // // @ts-expect-error - TODO snaps-controller does not export HandlerType.OnClientRequest - // handler: 'onClientRequest', - // request: { - // id: uuid(), - // jsonrpc: '2.0', - // method: 'signAndSendTransaction', - // params: { - // transaction, - // scope, - // accountId, - // ...(options && { options }), - // }, - // }, - // }; }; export const getTxMetaFields = ( From 44ec468c8d1bc84c148f93329e1f035cbf3a2ec1 Mon Sep 17 00:00:00 2001 From: Micaela Estabillo Date: Thu, 19 Mar 2026 17:03:37 -0700 Subject: [PATCH 12/12] chore: changelog --- packages/bridge-status-controller/CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index d7f4f7f728a..11f491bff50 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Moved more controller calls from bridge-status-controller.ts to their own utils ([#8215](https://github.com/MetaMask/core/pull/8215)) + ## [70.0.0] ### Changed