diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 31e58c889e..3898cdca2e 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add required `quoteId: string` to `QuoteResponseSchema`; quote responses without a `quoteId` will now fail validation ([#8462](https://github.com/MetaMask/core/pull/8462)) + ### Changed - Bump `@metamask/multichain-network-controller` from `^3.0.6` to `^3.1.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) diff --git a/packages/bridge-controller/src/bridge-controller.sse.test.ts b/packages/bridge-controller/src/bridge-controller.sse.test.ts index d53929d027..09f24c5eb9 100644 --- a/packages/bridge-controller/src/bridge-controller.sse.test.ts +++ b/packages/bridge-controller/src/bridge-controller.sse.test.ts @@ -87,6 +87,7 @@ const metricsContext = { stx_enabled: true, security_warnings: [], warnings: [], + token_security_type_destination: null, }; const assetExchangeRates = { diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 970f952435..22a7a02b36 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -3691,6 +3691,8 @@ describe('BridgeController', function () { bridgeIds: ['bridge1', 'bridge2'], fee: 0, }, + [FeatureId.QUICK_BUY]: undefined, + [FeatureId.DAPP_SWAP]: undefined, }, }); messengerCallMock.mockResolvedValueOnce('AUTH_TOKEN'); diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index e3247f25ad..116720f4de 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -471,6 +471,7 @@ export const QuoteResponseSchema = type({ TronTradeDataSchema, string(), ]), + quoteId: string(), }); export const validateQuoteResponse = ( diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json index bdab2ea388..5b1add70de 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json +++ b/packages/bridge-controller/tests/mock-quotes-erc20-erc20.json @@ -100,7 +100,8 @@ "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000005e00000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000004a0c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000019d0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000890000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000284792ebcb90000000000000000000000000000000000000000000000000000000000d59f80000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000c00000000000000000000000000000000000000000000000000000000000000120000000000000000000000000000000000000000000000000000000000000018000000000000000000000000000000000000000000000000000000000000001e0000000000000000000000000000000000000000000000000000000000000454000000000000000000000000000000000000000000000000000000000000000c40000000000000000000000000000000000000000000000000000000000000002000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c1883800000000000000000000000000000000000000000000000000000000000000020000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c335900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000d55a40000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000067041c47000000000000000000000000000000000000000000000000000000006704704d00000000000000000000000000000000000000000000000000000000d00dfeeddeadbeef765753be7f7a64d5509974b0d678e1e3149b02f42c7402906f9888136205038026f20b3f6df2899044cab41d632bc7a6c35debd40516df85de6f194aeb05b72cb9ea4d5ce0f7c56c91a79536331112f1a846dc641c", "gasLimit": 287227 }, - "estimatedProcessingTimeInSeconds": 60 + "estimatedProcessingTimeInSeconds": 60, + "quoteId": "90ae8e69-f03a-4cf6-bab7-ed4e3431eb37" }, { "quote": { @@ -203,6 +204,7 @@ "data": "0x3ce33bff00000000000000000000000000000000000000000000000000000000000000800000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c0000000000000000000000000000000000000000000000000000000000000000f736f636b6574416461707465725632000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004400000000000000000000000003a23f943181408eac424116af7b7790c94cb97a50000000000000000000000003a23f943181408eac424116af7b7790c94cb97a500000000000000000000000000000000000000000000000000000000000000890000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff850000000000000000000000003c499c542cef5e3811e1192ce70d8cc03d5c33590000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000001400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000716a8b9dd056055c84b7a2ba0a016099465a518700000000000000000000000000000000000000000000000000000000000002e4c3540448000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000005000000000000000000000000000000000000000000000000000000000000018c0000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000004000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000000000000000000000000000000000000000084ad69fa4f00000000000000000000000000000000000000000000000000038d7ea4c68000000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c18838000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000e4b7dfe9d00000000000000000000000000000000000000000000000000000000000d59f8000000000000000000000000000000000000000000000000000000000000000c4000000000000000000000000141d32a89a1e0a5ef360034a2f60a4b917c188380000000000000000000000000b2c639c533813f4aa9d7837caf62653d097ff85000000000000000000000000000000000000000000000000000000000000008900000000000000000000000000000000000000000000000000000000000000070000000000000000000000000000000000000000000000000000000000030d400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000138bc5930d51a475e4669db259f69e61ca33803675e76540f062a76af8cbaef4672c9926e56d6a8c29a263de3ee8f734ad760461c448f82fdccdd8c2360fffba1b", "gasLimit": 343079 }, - "estimatedProcessingTimeInSeconds": 1560 + "estimatedProcessingTimeInSeconds": 1560, + "quoteId": "0b6caac9-456d-47e6-8982-1945ae81ae82" } ] diff --git a/packages/bridge-controller/tests/mock-quotes-erc20-native.json b/packages/bridge-controller/tests/mock-quotes-erc20-native.json index f3e8d5c67b..98261e7fa1 100644 --- a/packages/bridge-controller/tests/mock-quotes-erc20-native.json +++ b/packages/bridge-controller/tests/mock-quotes-erc20-native.json @@ -107,7 +107,8 @@ "gasLimit": 841446, "effectiveGas": 641446 }, - "estimatedProcessingTimeInSeconds": 64 + "estimatedProcessingTimeInSeconds": 64, + "quoteId": "a63df72a-75ae-4416-a8ab-aff02596c75c" }, { "quote": { @@ -217,7 +218,8 @@ "gasLimit": 553352, "effectiveGas": 203352 }, - "estimatedProcessingTimeInSeconds": 53 + "estimatedProcessingTimeInSeconds": 53, + "quoteId": "aad73198-a64d-4310-b12d-9dcc81c412e2" }, { "quote": { @@ -327,7 +329,8 @@ "gasLimit": 277423, "effectiveGas": 177423 }, - "estimatedProcessingTimeInSeconds": 15 + "estimatedProcessingTimeInSeconds": 15, + "quoteId": "6cfd4952-c9b2-4aec-9349-af39c212f84b" }, { "quote": { @@ -437,7 +440,8 @@ "effectiveGas": 547501, "gasLimit": 647501 }, - "estimatedProcessingTimeInSeconds": 24.159 + "estimatedProcessingTimeInSeconds": 24.159, + "quoteId": "2c2ba7d8-3922-4081-9f27-63b7d5cc1986" }, { "quote": { @@ -542,7 +546,8 @@ "gasLimit": 282048, "effectiveGas": 182048 }, - "estimatedProcessingTimeInSeconds": 360 + "estimatedProcessingTimeInSeconds": 360, + "quoteId": "a77bc7b2-e8c8-4463-89db-5dd239d6aacc" }, { "quote": { @@ -936,6 +941,7 @@ "gasLimit": 910342, "effectiveGas": 710342 }, - "estimatedProcessingTimeInSeconds": 20 + "estimatedProcessingTimeInSeconds": 20, + "quoteId": "4f2154d9b330221b2ad461adf63acc2c" } ] diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json index 7989742f35..2f51d4f087 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20-eth.json @@ -134,7 +134,8 @@ "gasLimit": 540099, "effectiveGas": 540076 }, - "estimatedProcessingTimeInSeconds": 45 + "estimatedProcessingTimeInSeconds": 45, + "quoteId": "34c4136d-8558-4d87-bdea-eef8d2d30d6d" }, { "quote": { @@ -271,6 +272,7 @@ "gasLimit": 682999, "effectiveGas": 682910 }, - "estimatedProcessingTimeInSeconds": 1029.717 + "estimatedProcessingTimeInSeconds": 1029.717, + "quoteId": "5bf0f2f0-655c-4e13-a545-1ebad6f9d2bc" } ] diff --git a/packages/bridge-controller/tests/mock-quotes-native-erc20.json b/packages/bridge-controller/tests/mock-quotes-native-erc20.json index 4d06981004..727673fde2 100644 --- a/packages/bridge-controller/tests/mock-quotes-native-erc20.json +++ b/packages/bridge-controller/tests/mock-quotes-native-erc20.json @@ -154,7 +154,8 @@ "gasLimit": 610414, "effectiveGas": 610300 }, - "estimatedProcessingTimeInSeconds": 60 + "estimatedProcessingTimeInSeconds": 60, + "quoteId": "381c23bc-e3e4-48fe-bc53-257471e388ad" }, { "quote": { @@ -311,6 +312,7 @@ "gasLimit": 664389, "effectiveGas": 610300 }, - "estimatedProcessingTimeInSeconds": 15 + "estimatedProcessingTimeInSeconds": 15, + "quoteId": "4277a368-40d7-4e82-aa67-74f29dc5f98a" } ] diff --git a/packages/bridge-controller/tests/mock-quotes-sol-erc20.json b/packages/bridge-controller/tests/mock-quotes-sol-erc20.json index 65bda88645..452dfa8a2b 100644 --- a/packages/bridge-controller/tests/mock-quotes-sol-erc20.json +++ b/packages/bridge-controller/tests/mock-quotes-sol-erc20.json @@ -145,7 +145,8 @@ } }, "trade": "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=", - "estimatedProcessingTimeInSeconds": 12 + "estimatedProcessingTimeInSeconds": 12, + "quoteId": "5cb5a527-d4e4-4b5e-b753-136afc3986d3" }, { "quote": { @@ -293,6 +294,7 @@ } }, "trade": "AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQAIEnLY8oVRIwA8ZdRSGjM5RIZJW8Wv+Twyw3NqU4Hov+OHz7U6VQBhniAZG564p5JhG+y5+5uEABjxPtimE61bsqsz4TFeaDdmFmlW16xBf2qhUAUla7cIQjqp3HfLznM1aZqWVDV+zzkmftkF//DpbXxqeH8obNXHFR7pUlxG9uNVZ0EED+QHqrBQRqB+cbMfYZjXZcTe9r+CfdbguirL8P49t1pWG6qWtPmFmciR1xbrt4IW+b1nNcz2N5abYbCcsDgByJFz/oyJeNAhYJfn7erTZs6xJHjnuAV0v/cuH6iQNCzB1ajK9lOERjgtFNI8XDODau1kgDlDaRIGFfFNP09KMWgsU3Ye36HzgEdq38sqvZDFOifcDzPxfPOcDxeZgLShtMST0fB39lSGQI7f01fZv+JVg5S4qIF2zdmCAhSAAwZGb+UhFzL/7K26csOb57yM5bvF9xJrLEObOkAAAACMlyWPTiSJ8bs9ECkUjg2DC1oTmdr/EIQEjnvY2+n4WQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkEedVb8jHAbu50xW7OaBUH/bGy3qP0jlECsc2iVrwTj1E+LF26QsO9gzDavYNO6ZflUDWJ+gBV9eCQ5OcuzAMStD/6J/XX9kp0wJsfKVh53ksJqzbfyd1RSzIap7OM5egJanTpAxnCBLW4j9Mn+DAuluhVY4cEgRJ9Pah1VqYQXzWdRJXp28EMpR0GPlVtcnRtTGlBHjaRvhFYLMMzzMD6CQoABQLAXBUACgAJA0ANAwAAAAAACwYAAQIbDA0ACwYAAwAcDA0BAQwCAAMMAgAAAFBGFTsAAAAADQEDAREOKQ0PAAMEBQEcGw4OEA4dDx4SBAYTFBUNBxYICQ4fDwYFFxgZGiAhIiMNKMEgmzNB1pyBAwIAAAAaZAABOGQBAlBGFTsAAAAAP4hnBwAAAABkAAANAwMAAAEJEQUAAgEbDLwBj+v8wtNahk0AAAAAAAAAAAAAAAAUHTKomh4KXvNgA0ovYKS5F8GIOCjcXQcAAAAAAAAAAAAAAACUXhgAAAAAABb1AwAAAAAAGABuuH/gY8j1t421m3ekiET/qFVeKhVA3SJVS5OH/NW+oQMAAAAAAAAAAAAAAABCAAAAAAAAAAAAAAAAAAAAAAAAQrPV80YDAAAACwLaZwAAAAAAAAAAAAAAAAAAAAClqm4hcbQW4dJ+xTyowT2z+RqJzQADWk6DVOZO8lMFQg2r0dgfltD6tRL/B1hH3u00UzZdgqkAARE9whapJMxiYg1Y/S9bROWrjXfldZCFcyME/snbeFkkhAUXFisYKQMaKiVZfTkrqqg0GkW+iGFAaIHEbhkRX4YCBLoWvHI1OH2T2gSmTlKhBREUDA0H", - "estimatedProcessingTimeInSeconds": 120 + "estimatedProcessingTimeInSeconds": 120, + "quoteId": "12c94d29-4b5c-4aee-92de-76eee4172d3d" } ] diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 54c46f5e5c..6d09fd1fc3 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -7,8 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **BREAKING:** Add required `quoteId: string` to `BridgeHistoryItem` ([#8462](https://github.com/MetaMask/core/pull/8462)) +- Add `QuoteStatusUpdateManager` for resilient quote-status reporting to the Bridge API; reports `SUBMITTED`/`FINALIZED_SUCCESS`/`FINALIZED_FAILURE`, retries immediately on retryable errors (up to 6×), defers on network failures (every 30 min for up to 12 h), and persists the queue to `deferredStatusUpdates` state across service-worker restarts ([#8462](https://github.com/MetaMask/core/pull/8462)) +- Export `QuoteStatusUpdateError`, `QuoteStatusUpdateErrorType`, `QuoteStatusUpdateStatus`, `QuoteStatusUpdateResponse` and related enums ([#8462](https://github.com/MetaMask/core/pull/8462)) +- Add optional `onQuoteStatusUpdateError` and `isQuoteStatusUpdateEnabled` constructor options to `BridgeStatusController` ([#8462](https://github.com/MetaMask/core/pull/8462)) + ### Changed +- **BREAKING:** `BridgeStatusControllerMessenger` `AllowedEvents` now requires `TransactionControllerTransactionSubmittedEvent` ([#8462](https://github.com/MetaMask/core/pull/8462)) +- `BridgeStatusController` now subscribes to `TransactionController:transactionSubmitted` and handles `TransactionStatus.submitted` to report quote status for batch EVM (STX / 7702-delegated) transactions whose hash is unavailable at `submitTx` time ([#8462](https://github.com/MetaMask/core/pull/8462)) - Bump `@metamask/accounts-controller` from `^37.2.0` to `^38.0.0` ([#8665](https://github.com/MetaMask/core/pull/8665)) - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632)) - Bump `@metamask/keyring-controller` from `^25.2.0` to `^25.4.0` ([#8634](https://github.com/MetaMask/core/pull/8634), [#8665](https://github.com/MetaMask/core/pull/8665)) 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 8291c15791..3f5a635e06 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 @@ -114,6 +114,7 @@ exports[`BridgeStatusController constructor rehydrates the tx history state 1`] }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, "startTime": 1729964825189, "status": { @@ -369,6 +370,7 @@ exports[`BridgeStatusController startPollingForBridgeTxStatus sets the inital tx }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, "startTime": 1729964825189, "status": { @@ -474,7 +476,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": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -582,8 +584,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 8453, @@ -685,7 +688,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -733,7 +736,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -797,7 +800,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": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -905,8 +908,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 59144, @@ -1008,7 +1012,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -1056,7 +1060,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should delay after submitti "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -1121,7 +1125,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac exports[`BridgeStatusController submitTx: EVM bridge should handle smart transactions and include quotesReceivedContext 2`] = ` { "account": "0xaccount1", - "actionId": undefined, + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": "batchId1", "estimatedProcessingTimeInSeconds": 15, @@ -1229,8 +1233,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should handle smart transac }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -1421,7 +1426,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": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -1529,8 +1534,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -1632,7 +1638,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -1683,7 +1689,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -1747,7 +1753,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": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -1855,8 +1861,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -1958,7 +1965,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2009,7 +2016,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should not call handleMobil "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2073,7 +2080,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance 2`] = ` { "account": "0xaccount1", - "actionId": "1234567893.458", + "actionId": "1234567899.458", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2181,8 +2188,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -2284,7 +2292,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2335,7 +2343,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2386,7 +2394,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should reset USDT allowance "value": "0x0", }, { - "actionId": "1234567893.458", + "actionId": "1234567899.458", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2425,7 +2433,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": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2533,8 +2541,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -2636,7 +2645,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -2687,7 +2696,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -2726,7 +2735,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": "1234567891.456", + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -2834,8 +2843,9 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -2937,7 +2947,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should successfully submit "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -3039,7 +3049,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3167,7 +3177,7 @@ exports[`BridgeStatusController submitTx: EVM bridge should throw an error if ap "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3235,7 +3245,7 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confirmation before swap for hardware wallet on mobile 2`] = ` { "account": "0xaccount1", - "actionId": "1234567892.457", + "actionId": "1234567893.457", "approvalTxId": "test-approval-tx-id", "batchId": undefined, "estimatedProcessingTimeInSeconds": 15, @@ -3343,8 +3353,9 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -3446,7 +3457,7 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": true, @@ -3497,7 +3508,7 @@ exports[`BridgeStatusController submitTx: EVM bridge waits for approval tx confi "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567893.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": true, @@ -3643,7 +3654,7 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -3694,7 +3705,7 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567899.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -3710,7 +3721,7 @@ exports[`BridgeStatusController submitTx: EVM swap should gracefully handle isAt exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap transaction with approval 2`] = ` { "account": "0xaccount1", - "actionId": undefined, + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": "batchId1", "estimatedProcessingTimeInSeconds": 0, @@ -3808,8 +3819,9 @@ exports[`BridgeStatusController submitTx: EVM swap should handle a gasless swap }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -3845,7 +3857,7 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti exports[`BridgeStatusController submitTx: EVM swap should handle smart transactions 2`] = ` { "account": "0xaccount1", - "actionId": undefined, + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": "batchId1", "estimatedProcessingTimeInSeconds": 0, @@ -3953,8 +3965,9 @@ exports[`BridgeStatusController submitTx: EVM swap should handle smart transacti }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -4229,7 +4242,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum-client-id", "origin": "metamask", "requireApproval": false, @@ -4280,7 +4293,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567892.457", + "actionId": "1234567899.457", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -4315,7 +4328,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": "1234567891.456", + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4423,8 +4436,9 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -4526,7 +4540,7 @@ exports[`BridgeStatusController submitTx: EVM swap should successfully submit an "value": "0x0", }, { - "actionId": "1234567891.456", + "actionId": "1234567892.456", "networkClientId": "arbitrum", "origin": "metamask", "requireApproval": false, @@ -4601,7 +4615,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": "1234567891.456", + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4715,8 +4729,9 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -4751,7 +4766,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": "1234567891.456", + "actionId": "1234567892.456", "approvalTxId": undefined, "batchId": undefined, "estimatedProcessingTimeInSeconds": 0, @@ -4865,8 +4880,9 @@ exports[`BridgeStatusController submitTx: EVM swap should use quote txFee when g }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 42161, @@ -5049,7 +5065,7 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm "sourceTokenSymbol": "SOL", "status": "submitted", "swapTokenValue": "1", - "time": 1234567891, + "time": 1234567899, "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...", @@ -5155,8 +5171,9 @@ exports[`BridgeStatusController submitTx: Solana bridge should successfully subm }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 1151111081099710, @@ -5456,7 +5473,7 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit "sourceTokenSymbol": "SOL", "status": "submitted", "swapTokenValue": "1", - "time": 1234567891, + "time": 1234567899, "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...", @@ -5554,8 +5571,9 @@ exports[`BridgeStatusController submitTx: Solana swap should successfully submit }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 1151111081099710, @@ -5853,7 +5871,7 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "sourceTokenSymbol": "USDT", "status": "submitted", "swapTokenValue": "1", - "time": 1234567892, + "time": 1234567899, "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", @@ -5957,8 +5975,9 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 728126428, @@ -6078,7 +6097,7 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success "sourceTokenSymbol": "USDT", "status": "submitted", "swapTokenValue": "1", - "time": 1234567892, + "time": 1234567899, "txParams": { "data": "CgKquyIITd6G0PaK4+VAOmgIAbJjCjF0eXBlLmdvb2dsZWFwaXMuY29tL3Byb3RvY29sLlRyaWdnZXJTbWFydENvbnRyYWN0EjMKFUGPfqjM6fi7pn165ZzUmhll1hfnGxIVQaYU+AO2/XgJhqQseOycf3fm3tE8", "from": "TRX123...", @@ -6176,8 +6195,9 @@ exports[`BridgeStatusController submitTx: Tron swap with approval should success }, ], }, + "quoteId": "197c402f-cb96-4096-9f8c-54aed84ca776", "slippagePercentage": 0, - "startTime": 1234567890, + "startTime": 1234567891, "status": { "srcChain": { "chainId": 728126428, 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 e03bf27b4e..ab2f3b12a7 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -325,6 +325,7 @@ const getMockStartPollingForBridgeTxStatusArgs = ({ adjustedReturn: { valueInCurrency: null, usd: null }, swapRate: '1.234', cost: { valueInCurrency: null, usd: null }, + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', }, accountAddress: account, startTime: 1729964825189, @@ -348,6 +349,7 @@ const MockTxHistory = { actionId, originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, @@ -376,6 +378,7 @@ const MockTxHistory = { actionId, originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, @@ -409,6 +412,7 @@ const MockTxHistory = { originalTransactionId: txMetaId, batchId, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, @@ -448,6 +452,7 @@ const MockTxHistory = { actionId, originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime: 1729964825189, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, @@ -488,6 +493,7 @@ const MockTxHistory = { actionId, originalTransactionId: txMetaId, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime, estimatedProcessingTimeInSeconds: 15, slippagePercentage: 0, @@ -528,6 +534,7 @@ const MockTxHistory = { batchId, featureId: undefined, quote: getMockQuote({ srcChainId, destChainId }), + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', startTime: 1729964825189, completionTime: 1736277625746, estimatedProcessingTimeInSeconds: 15, @@ -649,7 +656,6 @@ function registerDefaultActionHandlers( configuration: { chainId: numberToHex(srcChainId), }, - // @ts-expect-error: Partial mock. provider: mockProvider, }), ); @@ -657,7 +663,6 @@ function registerDefaultActionHandlers( rootMessenger.registerActionHandler('TransactionController:getState', () => ({ transactions: [ { - // @ts-expect-error: this is ok id: txMetaId === 'undefined' ? undefined : txMetaId, hash: txHash, status, @@ -1608,7 +1613,6 @@ describe('BridgeStatusController', () => { ); rootMessenger.registerActionHandler( 'TransactionController:getState', - // @ts-expect-error: Partial mock. () => { getStateCallCount += 1; return { @@ -2001,6 +2005,7 @@ describe('BridgeStatusController', () => { describe('submitTx: Solana bridge', () => { const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2134,6 +2139,7 @@ describe('BridgeStatusController', () => { jest.clearAllTimers(); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValue(1234567899); mockMessengerCall = jest.fn(); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -2246,6 +2252,7 @@ describe('BridgeStatusController', () => { describe('submitTx: Solana swap', () => { const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', quote: { requestId: '123', srcChainId: ChainId.SOLANA, @@ -2380,6 +2387,7 @@ describe('BridgeStatusController', () => { mockMessengerCall = jest.fn(); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); + jest.spyOn(Date, 'now').mockReturnValue(1234567899); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -2502,6 +2510,7 @@ describe('BridgeStatusController', () => { const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', quote: { requestId: '123', srcChainId: ChainId.TRON, @@ -2636,6 +2645,7 @@ describe('BridgeStatusController', () => { jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Date, 'now').mockReturnValue(1234567899); mockMessengerCall = jest.fn(); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes }); @@ -2737,6 +2747,7 @@ describe('BridgeStatusController', () => { describe('submitTx: EVM bridge', () => { const mockEvmQuoteResponse = { + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', ...getMockQuote(), quote: { ...getMockQuote(), @@ -2848,6 +2859,7 @@ describe('BridgeStatusController', () => { jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567893); + jest.spyOn(Date, 'now').mockReturnValue(1234567899); jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); jest.spyOn(Math, 'random').mockReturnValueOnce(0.458); @@ -3745,6 +3757,7 @@ describe('BridgeStatusController', () => { describe('submitTx: EVM swap', () => { const mockEvmQuoteResponse = { + quoteId: '197c402f-cb96-4096-9f8c-54aed84ca776', ...getMockQuote(), quote: { ...getMockQuote(), @@ -3845,6 +3858,7 @@ describe('BridgeStatusController', () => { jest.spyOn(Date, 'now').mockReturnValueOnce(1234567890); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567891); jest.spyOn(Date, 'now').mockReturnValueOnce(1234567892); + jest.spyOn(Date, 'now').mockReturnValue(1234567899); jest.spyOn(Math, 'random').mockReturnValueOnce(0.456); jest.spyOn(Math, 'random').mockReturnValueOnce(0.457); mockMessengerCall.mockImplementationOnce(jest.fn()); // stopPollingForQuotes @@ -4594,6 +4608,56 @@ describe('BridgeStatusController', () => { ); }); + it('removes the pre-submission history item when batch EVM submission fails', async () => { + const mockActionId = '9999999999.000'; + jest + .spyOn(transactionUtils, 'generateActionId') + .mockReturnValue(mockActionId); + + setupEventTrackingMocks(mockMessengerCall); + mockMessengerCall.mockReturnValueOnce(mockSelectedAccount); // getAccountByAddress (for batch params) + mockMessengerCall.mockReturnValueOnce('arbitrum'); // getNetworkClientIdByChainId + addTransactionBatchFn.mockRejectedValueOnce( + new Error('User rejected the batch transaction'), + ); + + const { approval, ...quoteWithoutApproval } = mockEvmQuoteResponse; + await withController( + { mockMessengerCall }, + async ({ controller, rootMessenger }) => { + await expect( + rootMessenger.call( + 'BridgeStatusController:submitTx', + (mockEvmQuoteResponse.trade as TxData).from, + { + ...quoteWithoutApproval, + quote: { + ...quoteWithoutApproval.quote, + gasIncluded: true, + gasIncluded7702: true, + feeData: { + ...quoteWithoutApproval.quote.feeData, + txFee: { + maxFeePerGas: '1395348', + maxPriorityFeePerGas: '1000001', + }, + }, + }, + }, + false, // STX off, gasIncluded7702=true forces batch path + ), + ).rejects.toThrow('User rejected the batch transaction'); + + // The ghost history item keyed by batchActionId must be deleted on failure. + // Without the fix it would linger with txMetaId: undefined and status: PENDING, + // skipped forever by restartPollingForIncompleteHistoryItems, and accumulate + // on every subsequent failed batch submission. + expect(controller.state.txHistory[mockActionId]).toBeUndefined(); + expect(Object.keys(controller.state.txHistory)).toHaveLength(0); + }, + ); + }); + it('should gracefully handle isAtomicBatchSupported failure', async () => { // Manually set up mocks without setupEventTrackingMocks // to control the isAtomicBatchSupported mock @@ -5862,6 +5926,321 @@ describe('BridgeStatusController', () => { }); }); + describe('TransactionController:transactionStatusUpdated (submitted)', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ ok: true } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('calls reportSubmitted and persists deferredStatusUpdates when bridge tx reaches submitted with hash and quoteId', async () => { + await withController( + { + options: { + isQuoteStatusUpdateEnabled: () => true, + state: { + txHistory: MockTxHistory.getPending(), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + hash: '0xsrcTxHash1', + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }, + ); + + // State is updated synchronously via persistDeferredUpdates before any async work + expect( + Object.keys(controller.state.deferredStatusUpdates), + ).toHaveLength(1); + expect( + Object.values(controller.state.deferredStatusUpdates)[0] + .pendingStatuses, + ).toStrictEqual(['SUBMITTED']); + + await flushPromises(); + controller.stopAllPolling(); + }, + ); + }); + + it('does not call reportSubmitted when txMeta has no hash', async () => { + await withController( + { + options: { + isQuoteStatusUpdateEnabled: () => true, + state: { + txHistory: MockTxHistory.getPending(), + }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + hash: undefined, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }, + ); + + expect(controller.state.deferredStatusUpdates).toStrictEqual({}); + + await flushPromises(); + controller.stopAllPolling(); + }, + ); + }); + + it('does not call reportSubmitted when historyItem has no quoteId', async () => { + const historyWithoutQuoteId: Record = { + bridgeTxMetaId1: { + ...MockTxHistory.getPending().bridgeTxMetaId1, + quoteId: undefined as unknown as string, + }, + }; + await withController( + { + options: { + isQuoteStatusUpdateEnabled: () => true, + state: { txHistory: historyWithoutQuoteId }, + }, + }, + async ({ controller, rootMessenger }) => { + rootMessenger.publish( + 'TransactionController:transactionStatusUpdated', + { + transactionMeta: { + hash: '0xsrcTxHash1', + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }, + ); + + expect(controller.state.deferredStatusUpdates).toStrictEqual({}); + + await flushPromises(); + controller.stopAllPolling(); + }, + ); + }); + }); + + describe('TransactionController:transactionSubmitted', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ ok: true } as Response); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it('calls reportSubmitted and persists deferredStatusUpdates when transactionSubmitted fires with hash and quoteId', async () => { + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: [], + events: ['TransactionController:transactionSubmitted'], + }); + registerDefaultActionHandlers(rootMessenger); + + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + isQuoteStatusUpdateEnabled: () => true, + state: { txHistory: MockTxHistory.getPending() }, + }); + + rootMessenger.publish('TransactionController:transactionSubmitted', { + transactionMeta: { + hash: '0xsrcTxHash1', + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }); + + // State is updated synchronously via persistDeferredUpdates before any async work + expect(Object.keys(controller.state.deferredStatusUpdates)).toHaveLength( + 1, + ); + expect( + Object.values(controller.state.deferredStatusUpdates)[0] + .pendingStatuses, + ).toStrictEqual(['SUBMITTED']); + + await flushPromises(); + controller.stopAllPolling(); + }); + + it('looks up history by actionId when txMetaId is not found', async () => { + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: [], + events: ['TransactionController:transactionSubmitted'], + }); + registerDefaultActionHandlers(rootMessenger); + + const historyKeyedByActionId: Record = { + 'pre-submission-action-id': { + ...MockTxHistory.getPending().bridgeTxMetaId1, + txMetaId: undefined, + actionId: 'pre-submission-action-id', + }, + }; + + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + isQuoteStatusUpdateEnabled: () => true, + state: { txHistory: historyKeyedByActionId }, + }); + + rootMessenger.publish('TransactionController:transactionSubmitted', { + transactionMeta: { + hash: '0xsrcTxHash1', + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'unknown-tx-meta-id', + actionId: 'pre-submission-action-id', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }); + + expect(Object.keys(controller.state.deferredStatusUpdates)).toHaveLength( + 1, + ); + + await flushPromises(); + controller.stopAllPolling(); + }); + + it('does not call reportSubmitted when tx has no hash', async () => { + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: [], + events: ['TransactionController:transactionSubmitted'], + }); + registerDefaultActionHandlers(rootMessenger); + + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + isQuoteStatusUpdateEnabled: () => true, + state: { txHistory: MockTxHistory.getPending() }, + }); + + rootMessenger.publish('TransactionController:transactionSubmitted', { + transactionMeta: { + hash: undefined, + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'bridgeTxMetaId1', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }); + + expect(controller.state.deferredStatusUpdates).toStrictEqual({}); + + await flushPromises(); + controller.stopAllPolling(); + }); + + it('does not call reportSubmitted when txMetaId not in history and actionId is absent', async () => { + const rootMessenger = getRootMessenger(); + const messenger = getControllerMessenger(rootMessenger); + rootMessenger.delegate({ + messenger, + actions: [], + events: ['TransactionController:transactionSubmitted'], + }); + registerDefaultActionHandlers(rootMessenger); + + const controller = new BridgeStatusController({ + messenger, + clientId: BridgeClientId.EXTENSION, + fetchFn: jest.fn(), + addTransactionBatchFn, + isQuoteStatusUpdateEnabled: () => true, + state: { txHistory: MockTxHistory.getPending() }, + }); + + // txMetaId ('unknown-id') not in history AND no actionId provided + rootMessenger.publish('TransactionController:transactionSubmitted', { + transactionMeta: { + hash: '0xsrcTxHash1', + type: TransactionType.bridge, + status: TransactionStatus.submitted, + id: 'unknown-id', + chainId: CHAIN_IDS.ARBITRUM, + networkClientId: 'eth-id', + time: Date.now(), + txParams: {} as unknown as TransactionParams, + }, + }); + + expect(controller.state.deferredStatusUpdates).toStrictEqual({}); + + await flushPromises(); + controller.stopAllPolling(); + }); + }); + describe('metadata', () => { it('includes expected state in debug snapshots', async () => { await withController(async ({ controller }) => { @@ -5901,6 +6280,7 @@ describe('BridgeStatusController', () => { ), ).toMatchInlineSnapshot(` { + "deferredStatusUpdates": {}, "txHistory": {}, } `); diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 3265f7c6e3..1467e632de 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -41,6 +41,8 @@ import { MAX_ATTEMPTS, REFRESH_INTERVAL_MS, } from './constants'; +import { QuoteStatusUpdateError } from './errors'; +import { QuoteStatusUpdateManager } from './quote-status-update-manager'; import type { BridgeStatusControllerState, StartPollingForBridgeTxStatusArgsSerialized, @@ -114,6 +116,13 @@ const metadata: StateMetadata = { includeInDebugSnapshot: false, usedInUi: true, }, + // Deferred status updates used by QuoteStatusUpdateManager + deferredStatusUpdates: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: false, + }, }; /** The input to start polling for the {@link BridgeStatusController} */ @@ -143,6 +152,8 @@ export class BridgeStatusController extends StaticIntervalPollingController; @@ -173,6 +186,8 @@ export class BridgeStatusController extends StaticIntervalPollingController void; + isQuoteStatusUpdateEnabled?: () => boolean; }) { super({ name: BRIDGE_STATUS_CONTROLLER_NAME, @@ -198,6 +213,19 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.update((draft) => { + draft.deferredStatusUpdates = updates; + }); + }, + onError: onQuoteStatusUpdateError, + isEnabled: isQuoteStatusUpdateEnabled, + }); // Register action handlers this.messenger.registerMethodActionHandlers( @@ -232,6 +260,24 @@ export class BridgeStatusController extends StaticIntervalPollingController { + const { type, id: txMetaId, hash, actionId } = transactionMeta; + if (hash && type && isCrossChainTx(type)) { + const historyItem = + this.state.txHistory[txMetaId] ?? + (actionId ? this.state.txHistory[actionId] : undefined); + if (historyItem?.quoteId) { + this.#quoteStatusUpdateManager.reportSubmitted( + historyItem.quoteId, + hash, + txMetaId, + ); + } + } + }, + ); + // If you close the extension, but keep the browser open, the polling continues // If you close the browser, the polling stops // Check for historyItems that do not have a status of complete and restart polling @@ -295,6 +363,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { + this.#quoteStatusUpdateManager.destroy(); this.update((state) => { state.txHistory = DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.txHistory; + state.deferredStatusUpdates = + DEFAULT_BRIDGE_STATUS_CONTROLLER_STATE.deferredStatusUpdates; }); }; @@ -556,10 +637,11 @@ export class BridgeStatusController extends StaticIntervalPollingController { this.update((state) => { rekeyHistoryItemInState(state, actionId, txMeta); @@ -642,6 +724,10 @@ export class BridgeStatusController extends StaticIntervalPollingController { if (!historyKey) { return; @@ -916,6 +1011,9 @@ export class BridgeStatusController extends StaticIntervalPollingController Promise, +): { + messenger: BridgeStatusControllerMessenger; +} { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const rootMessenger = new Messenger({ namespace: MOCK_ANY_NAMESPACE }) as any; + const messenger = new Messenger({ + namespace: BRIDGE_STATUS_CONTROLLER_NAME, + parent: rootMessenger, + }) as unknown as BridgeStatusControllerMessenger; + + rootMessenger.delegate({ + messenger, + actions: ['AuthenticationController:getBearerToken'], + events: [], + }); + + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + getBearerTokenHandler ?? + ((): Promise => Promise.resolve(JWT_TOKEN)), + ); + + return { messenger }; +} + +function buildEntry( + overrides: Partial = {}, +): DeferredStatusUpdateEntry { + return { + quoteId: QUOTE_ID, + srcTxHash: SRC_TX_HASH, + pendingStatuses: [QuoteStatusUpdateStatus.Submitted], + createdAt: Date.now(), + lastAttemptAt: Date.now(), + txMetaId: TX_META_ID, + ...overrides, + }; +} + +function buildManager( + overrides: Partial< + ConstructorParameters[0] + > = {}, +): { + manager: QuoteStatusUpdateManager; + persistDeferredUpdates: jest.Mock; + onError: jest.Mock; +} { + const { messenger } = buildRootAndChildMessenger(); + const persistDeferredUpdates = jest.fn(); + const onError = jest.fn(); + + const manager = new QuoteStatusUpdateManager({ + messenger, + clientId: BridgeClientId.EXTENSION, + apiBaseUrl: API_BASE_URL, + persistDeferredUpdates, + onError, + isEnabled: (): boolean => true, + ...overrides, + }); + + return { manager, persistDeferredUpdates, onError }; +} + +function mockFetchOk(): jest.SpyInstance { + return jest + .spyOn(globalThis, 'fetch') + .mockResolvedValue({ ok: true } as Response); +} + +function mockFetchError(body: Record): jest.SpyInstance { + return jest.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + json: (): Promise> => Promise.resolve(body), + } as unknown as Response); +} + +function mockFetchNetworkError(): jest.SpyInstance { + return jest + .spyOn(globalThis, 'fetch') + .mockRejectedValue(new Error('Network error')); +} + +/** + * Advances fake time by `delay` ms while interleaving promise microtasks. + * + * @param delay - Milliseconds to advance fake time by. + */ +async function advanceAndFlush(delay: number): Promise { + await jest.advanceTimersByTimeAsync(delay); +} + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('QuoteStatusUpdateManager', () => { + let fetchSpy: jest.SpyInstance; + + beforeEach(() => { + jest.useFakeTimers(); + jest.clearAllMocks(); + }); + + afterEach(() => { + fetchSpy?.mockRestore(); + jest.useRealTimers(); + }); + + // ── Constructor ──────────────────────────────────────────────────────────── + + describe('constructor', () => { + it('initialises with an empty queue when no initialDeferredUpdates provided', () => { + const { persistDeferredUpdates } = buildManager(); + + // No persist call on construction when queue is empty + expect(persistDeferredUpdates).not.toHaveBeenCalled(); + }); + + it('loads initial deferred updates, processes them, and drains the queue', async () => { + fetchSpy = mockFetchOk(); + const entry = buildEntry(); + const { persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: entry }, + }); + + // Fire the setTimeout(0) stagger and let the async chain complete + await advanceAndFlush(0); + await flushPromises(); + + // One fetch call for the initial entry + expect(fetchSpy).toHaveBeenCalledTimes(1); + // Queue drained — last persist is empty + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + + it('clones entries so mutations do not affect the initial frozen state', () => { + const entry = buildEntry(); + // Simulate Immer-frozen state by freezing the entry + Object.freeze(entry); + Object.freeze(entry.pendingStatuses); + + expect(() => + buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: entry }, + }), + ).not.toThrow(); + }); + + it('drops expired initial entries and calls onError for each', () => { + const expiredEntry = buildEntry({ + createdAt: + Date.now() - QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS - 1000, + }); + const { onError, persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: expiredEntry }, + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(QuoteStatusUpdateError); + // Persists the empty queue after dropping expired entries + expect(persistDeferredUpdates).toHaveBeenCalledWith({}); + }); + + it('starts the retry timer when there are initial non-expired entries', async () => { + fetchSpy = mockFetchNetworkError(); + const entry = buildEntry(); + buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: entry }, + }); + + // Fire the initial stagger setTimeout(0) + await advanceAndFlush(0); + await flushPromises(); + const callsAfterFirst = fetchSpy.mock.calls.length; + + // Advance past one retry interval — should fire #processDeferredRetries + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS + 1000); + + // At least one additional fetch attempt after the retry interval + expect(fetchSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('processes multiple initial entries with a 125 ms stagger', async () => { + fetchSpy = mockFetchOk(); + const entry1 = buildEntry({ quoteId: 'q1', srcTxHash: '0xhash1' }); + const entry2 = buildEntry({ quoteId: 'q2', srcTxHash: '0xhash2' }); + + buildManager({ + initialDeferredUpdates: { + 'q1:0xhash1': entry1, + 'q2:0xhash2': entry2, + }, + }); + + // No fetches yet — both are scheduled with setTimeout + expect(fetchSpy).not.toHaveBeenCalled(); + + // Fire setTimeout(0) for first entry + await advanceAndFlush(0); + + // First entry processed at t=0 + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Fire setTimeout(125) for second entry + await advanceAndFlush(125); + await flushPromises(); + + // Second entry processed at t=125 + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + + // ── #processSingleEntry guard branches ──────────────────────────────────── + + it('skips processing in the stagger callback when isEnabled returns false (lines 253-254)', async () => { + fetchSpy = mockFetchOk(); + const entry = buildEntry(); + const { persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: entry }, + isEnabled: (): boolean => false, + }); + + // Fire the stagger setTimeout(0) + await advanceAndFlush(0); + + // No fetch — #processSingleEntry returned early because isEnabled() is false + expect(fetchSpy).not.toHaveBeenCalled(); + // No state change — the early return did not call #removeEntry or #persistToState + expect(persistDeferredUpdates).not.toHaveBeenCalledWith({}); + }); + + it('skips processing via the retry timer when isEnabled returns false (lines 253-254)', async () => { + fetchSpy = mockFetchNetworkError(); + let enabled = true; + const { manager } = buildManager({ + isEnabled: (): boolean => enabled, + }); + + // Enqueue while enabled — initial send fails, timer starts + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const callsAfterFirst = fetchSpy.mock.calls.length; + + // Disable before the next retry interval + enabled = false; + + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS + 1000); + + // No additional fetches while disabled + expect(fetchSpy.mock.calls).toHaveLength(callsAfterFirst); + }); + + it('removes an entry with empty pendingStatuses via stagger (lines 265-267)', async () => { + fetchSpy = mockFetchOk(); + const emptyEntry = buildEntry({ pendingStatuses: [] }); + const { persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: emptyEntry }, + }); + + // Fire the stagger — #processSingleEntry sees pendingStatuses.length === 0 + await advanceAndFlush(0); + await flushPromises(); + + // No fetch — entry removed before any API call + expect(fetchSpy).not.toHaveBeenCalled(); + // Queue drained by defensive cleanup + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + + it('evicts an expired entry inside #processSingleEntry via reportFinalised (lines 274-284)', async () => { + // Strategy: load an entry that is 1 ms short of expiry so #dropExpiredEntries + // in the constructor does NOT evict it. Keep isEnabled=false so the stagger + // returns early at line 253. After advancing time 2 ms past the expiry + // boundary, toggle isEnabled=true and call reportFinalised, which calls + // #processSingleEntry directly (bypassing #dropExpiredEntries). At that + // point Date.now() is past the boundary and lines 274-284 are hit. + fetchSpy = mockFetchOk(); + let enabled = false; + const almostExpiredEntry = buildEntry({ + createdAt: Date.now() - (QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS - 1), + }); + const { manager, onError, persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: almostExpiredEntry }, + isEnabled: (): boolean => enabled, + }); + + // Fire the stagger (isEnabled=false → early return at 253-254; no eviction) + // then advance 2 ms so Date.now() is 1 ms past the expiry boundary. + await advanceAndFlush(2); + + // Enable and trigger reportFinalised — it calls #processSingleEntry directly + enabled = true; + manager.reportFinalised(TX_META_ID, true); + + // Entry is expired inside #processSingleEntry → evicted at lines 274-284 + expect(fetchSpy).not.toHaveBeenCalled(); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(QuoteStatusUpdateError); + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + }); + + // ── reportSubmitted ──────────────────────────────────────────────────────── + + describe('reportSubmitted', () => { + it('does nothing when isEnabled is not provided', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager({ + isEnabled: undefined, + }); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + expect(persistDeferredUpdates).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('does nothing when isEnabled returns false', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager({ + isEnabled: () => false, + }); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + expect(persistDeferredUpdates).not.toHaveBeenCalled(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('enqueues a SUBMITTED entry and immediately persists state', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + expect(persistDeferredUpdates).toHaveBeenCalledTimes(1); + const persisted = persistDeferredUpdates.mock.calls[0][0]; + expect(persisted[QUEUE_KEY]).toMatchObject({ + quoteId: QUOTE_ID, + srcTxHash: SRC_TX_HASH, + txMetaId: TX_META_ID, + pendingStatuses: [QuoteStatusUpdateStatus.Submitted], + }); + }); + + it('immediately attempts to send the SUBMITTED status', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe(`${API_BASE_URL}/quote/updateStatus`); + expect(JSON.parse((init as RequestInit).body as string)).toStrictEqual({ + quoteId: QUOTE_ID, + newStatus: QuoteStatusUpdateStatus.Submitted, + srcTxHash: SRC_TX_HASH, + }); + }); + + it('sends the JWT in the request headers', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const [, init] = fetchSpy.mock.calls[0]; + expect((init as RequestInit).headers).toMatchObject({ + Authorization: `Bearer ${JWT_TOKEN}`, + }); + }); + + it('removes the entry from persisted state after a successful send', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + // Last persist call should contain an empty record (entry removed) + const lastCall = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastCall).toStrictEqual({}); + }); + }); + + // ── reportFinalised ──────────────────────────────────────────────────────── + + describe('reportFinalised', () => { + it('does nothing when isEnabled returns false', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager({ + isEnabled: () => false, + }); + + manager.reportFinalised(TX_META_ID, true); + + expect(persistDeferredUpdates).not.toHaveBeenCalled(); + }); + + it('does nothing when no matching entry is found by txMetaId', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportFinalised('non-existent-tx-meta-id', true); + + expect(persistDeferredUpdates).not.toHaveBeenCalled(); + }); + + it('appends FINALIZED_SUCCESS when success is true', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + // Enqueue SUBMITTED — SUBMITTED is in-flight immediately + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + // Append FINALIZED_SUCCESS while SUBMITTED is in-flight + manager.reportFinalised(TX_META_ID, true); + + // Verify FINALIZED_SUCCESS was appended before any async work completes + const pendingCall = persistDeferredUpdates.mock.calls.find((call) => + call[0][QUEUE_KEY]?.pendingStatuses.includes( + QuoteStatusUpdateStatus.FinalizedSuccess, + ), + ); + expect(pendingCall).toBeDefined(); + + await flushPromises(); + }); + + it('appends FINALIZED_FAILURE when success is false', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + manager.reportFinalised(TX_META_ID, false); + + const failureCall = persistDeferredUpdates.mock.calls.find((call) => + call[0][QUEUE_KEY]?.pendingStatuses.includes( + QuoteStatusUpdateStatus.FinalizedFailed, + ), + ); + expect(failureCall).toBeDefined(); + }); + + it('triggers immediate processing when no send is in-flight', async () => { + // First call: network error — entry stays, mutex released + // Second call: both SUBMITTED and FINALIZED_SUCCESS succeed + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockRejectedValueOnce(new Error('network error')) + .mockResolvedValue({ ok: true } as Response); + + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); // SUBMITTED fails → entry stays, mutex released + + // Entry is in queue but NOT in-flight — reportFinalised triggers immediate processing + manager.reportFinalised(TX_META_ID, true); + await flushPromises(); // Processes SUBMITTED then FINALIZED_SUCCESS + + // 1 failed SUBMITTED + 1 re-sent SUBMITTED + 1 FINALIZED_SUCCESS = 3 + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect( + JSON.parse( + ( + fetchSpy.mock.calls[ + fetchSpy.mock.calls.length - 1 + ][1] as RequestInit + ).body as string, + ).newStatus, + ).toBe(QuoteStatusUpdateStatus.FinalizedSuccess); + }); + + it('does not trigger a second concurrent processing when a send is in-flight', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + // SUBMITTED is in-flight + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + // While SUBMITTED is in-flight, reportFinalised should NOT call processSingleEntry + // (it should just append and wait for the in-flight chain to continue) + manager.reportFinalised(TX_META_ID, true); + + await flushPromises(); + + // Both statuses sent in sequence (2 total) + expect(fetchSpy).toHaveBeenCalledTimes(2); + }); + }); + + // ── destroy ──────────────────────────────────────────────────────────────── + + describe('destroy', () => { + it('stops the retry timer', async () => { + fetchSpy = mockFetchNetworkError(); + const entry = buildEntry(); + const { manager } = buildManager({ + initialDeferredUpdates: { [QUEUE_KEY]: entry }, + }); + + // Fire the initial stagger + await advanceAndFlush(0); + await flushPromises(); + + manager.destroy(); + + const callCountAfterDestroy = fetchSpy.mock.calls.length; + + // Advance past several retry intervals — no new fetches should fire + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS * 3); + + expect(fetchSpy.mock.calls).toHaveLength(callCountAfterDestroy); + }); + + it('clears the in-memory queue', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + manager.destroy(); + + persistDeferredUpdates.mockClear(); + + // After destroy, reportSubmitted on same key enqueues fresh without conflict + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + expect(persistDeferredUpdates).toHaveBeenCalledTimes(1); + }); + }); + + // ── API request ──────────────────────────────────────────────────────────── + + describe('API request', () => { + it('uses the correct endpoint URL', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + expect(fetchSpy.mock.calls[0][0]).toBe( + `${API_BASE_URL}/quote/updateStatus`, + ); + }); + + it('uses POST method with JSON content-type', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + expect((init.headers as Record)['Content-Type']).toBe( + 'application/json', + ); + }); + + it('falls back to no Authorization header when getBearerToken throws', async () => { + // Build a fresh messenger where getBearerToken always throws + const { messenger } = buildRootAndChildMessenger(() => { + throw new Error('auth failed'); + }); + fetchSpy = mockFetchOk(); + const persistDeferredUpdates = jest.fn(); + const manager = new QuoteStatusUpdateManager({ + messenger, + clientId: BridgeClientId.EXTENSION, + apiBaseUrl: API_BASE_URL, + persistDeferredUpdates, + isEnabled: (): boolean => true, + }); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + expect( + (init.headers as Record).Authorization, + ).toBeUndefined(); + }); + }); + + // ── Retry logic ──────────────────────────────────────────────────────────── + + describe('retry logic', () => { + describe('on network failure', () => { + it('keeps the entry in the queue and updates lastAttemptAt', async () => { + fetchSpy = mockFetchNetworkError(); + const { manager, persistDeferredUpdates } = buildManager(); + + const before = Date.now(); + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted[QUEUE_KEY]).toBeDefined(); + expect(lastPersisted[QUEUE_KEY].lastAttemptAt).toBeGreaterThanOrEqual( + before, + ); + }); + + it('retries after QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS', async () => { + fetchSpy = mockFetchNetworkError(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const callsAfterFirst = fetchSpy.mock.calls.length; + + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS + 1000); + + expect(fetchSpy.mock.calls.length).toBeGreaterThan(callsAfterFirst); + }); + + it('does not start a second concurrent send for the same key', async () => { + let resolveFirst!: () => void; + fetchSpy = jest.spyOn(globalThis, 'fetch').mockImplementation( + (): Promise => + new Promise((resolve) => { + resolveFirst = (): void => resolve({ ok: true } as Response); + }), + ); + + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + // Let async chain reach the fetch call (first send is now in-flight) + await flushPromises(); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Second call — entry exists but in-flight guard should prevent second send + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + resolveFirst(); + await flushPromises(); + }); + }); + + describe(`on ${QuoteStatusUpdateErrorType.ConcurrentUpdate} error`, () => { + it(`retries immediately up to ${QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES} times then keeps in deferred queue`, async () => { + fetchSpy = mockFetchError({ + type: QuoteStatusUpdateErrorType.ConcurrentUpdate, + }); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + // Each attempt (except the first) requires sleep(5000). Advance through all retries. + await advanceAndFlush( + QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS * + (QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1), + ); + + expect(fetchSpy).toHaveBeenCalledTimes( + QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1, + ); + + // Entry still in deferred queue (not evicted) + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted[QUEUE_KEY]).toBeDefined(); + }); + }); + + describe(`on ${QuoteStatusUpdateErrorType.TransactionNotIndexed} error`, () => { + it('retries immediately the same number of times as ConcurrentUpdate', async () => { + fetchSpy = mockFetchError({ + type: QuoteStatusUpdateErrorType.TransactionNotIndexed, + }); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + await advanceAndFlush( + QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS * + (QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1), + ); + + expect(fetchSpy).toHaveBeenCalledTimes( + QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1, + ); + }); + }); + + describe(`on ${QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch} error`, () => { + it('attempts finalization with the corrected status from the response', async () => { + const correctedStatus = QuoteStatusUpdateStatus.FinalizedSuccess; + + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + type: QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch, + currentStatus: correctedStatus, + newStatus: QuoteStatusUpdateStatus.Submitted, + statusCode: 400, + message: 'mismatch', + }), + } as unknown as Response) + .mockResolvedValue({ ok: true } as Response); + + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + // Second fetch should be for the corrected finalization status + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect( + JSON.parse((fetchSpy.mock.calls[1][1] as RequestInit).body as string) + .newStatus, + ).toBe(correctedStatus); + + // Entry removed after successful finalization + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + + it(`retries finalization up to ${QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES} times on network failure then evicts`, async () => { + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + type: QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch, + currentStatus: QuoteStatusUpdateStatus.FinalizedSuccess, + newStatus: QuoteStatusUpdateStatus.Submitted, + statusCode: 400, + message: 'mismatch', + }), + } as unknown as Response) + .mockRejectedValue(new Error('finalization network error')); + + const { manager, onError } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + // Advance through all finalization retries + await advanceAndFlush( + QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS * + (QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1), + ); + + // 1 SUBMITTED + (IMMEDIATE_MAX_RETRIES + 1) finalization attempts + expect(fetchSpy.mock.calls).toHaveLength( + 1 + QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES + 1, + ); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError.mock.calls[0][0]).toBeInstanceOf(QuoteStatusUpdateError); + }); + }); + + describe(`on ${QuoteStatusUpdateErrorType.InvalidStatusTransaction} error`, () => { + it('attempts finalization with the corrected status', async () => { + const correctedStatus = QuoteStatusUpdateStatus.FinalizedFailed; + + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce({ + ok: false, + json: () => + Promise.resolve({ + type: QuoteStatusUpdateErrorType.InvalidStatusTransaction, + currentStatus: correctedStatus, + newStatus: QuoteStatusUpdateStatus.Submitted, + statusCode: 400, + message: 'invalid', + }), + } as unknown as Response) + .mockResolvedValue({ ok: true } as Response); + + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + expect(fetchSpy).toHaveBeenCalledTimes(2); + expect( + JSON.parse((fetchSpy.mock.calls[1][1] as RequestInit).body as string) + .newStatus, + ).toBe(correctedStatus); + + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + }); + + describe('on other non-retryable errors', () => { + it.each([ + QuoteStatusUpdateErrorType.QuoteNotFound, + QuoteStatusUpdateErrorType.SvmTradeDeserializeFailed, + QuoteStatusUpdateErrorType.TxDataMismatch, + QuoteStatusUpdateErrorType.TxDataMissingHash, + QuoteStatusUpdateErrorType.PersistQuoteStatusFailed, + ])('evicts the entry and calls onError for %s', async (errorType) => { + fetchSpy = mockFetchError({ + type: errorType, + statusCode: 400, + message: 'error', + }); + const { manager, onError, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + // Entry evicted + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + + // Error reported + expect(onError).toHaveBeenCalledTimes(1); + const error = onError.mock.calls[0][0] as QuoteStatusUpdateError; + expect(error).toBeInstanceOf(QuoteStatusUpdateError); + expect(error.details?.errorType).toBe(errorType); + }); + }); + + describe('expiry', () => { + it('evicts entries that exceed QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS when a retry interval fires after expiry', async () => { + fetchSpy = mockFetchNetworkError(); + const { manager, onError, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + // Advance past max lifetime AND one full retry interval so the + // next #processDeferredRetries call sees the entry as expired. + await advanceAndFlush( + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS + + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS + + 1000, + ); + + // Entry evicted by #dropExpiredEntries or #processSingleEntry + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + expect(onError).toHaveBeenCalled(); + expect(onError.mock.calls[0][0]).toBeInstanceOf(QuoteStatusUpdateError); + }); + + it('evicts stale entries inside #processSingleEntry before sending', async () => { + fetchSpy = mockFetchNetworkError(); + const { manager, onError } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + // Advance far past max lifetime + multiple retry intervals + await advanceAndFlush( + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS + + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS * 2, + ); + + expect(onError).toHaveBeenCalled(); + }); + + it('stops the retry timer when the queue becomes empty after success', async () => { + fetchSpy = mockFetchOk(); + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const callCountAfterSuccess = fetchSpy.mock.calls.length; + + // After success the queue is empty and the timer stops + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS * 2); + + expect(fetchSpy.mock.calls).toHaveLength(callCountAfterSuccess); + }); + }); + + describe('deferred retry timer', () => { + it('fires every QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS and retries all pending entries', async () => { + fetchSpy = mockFetchNetworkError(); + const { manager } = buildManager(); + + manager.reportSubmitted('q1', '0xhash1', 'meta1'); + manager.reportSubmitted('q2', '0xhash2', 'meta2'); + await flushPromises(); + + const initialCalls = fetchSpy.mock.calls.length; // 2 + + await advanceAndFlush(QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS + 500); + + // Both entries retried + expect(fetchSpy.mock.calls).toHaveLength(initialCalls + 2); + }); + }); + + describe('FIFO ordering of pending statuses', () => { + it('sends SUBMITTED before FINALIZED_SUCCESS', async () => { + const sentStatuses: string[] = []; + fetchSpy = jest + .spyOn(globalThis, 'fetch') + .mockImplementation( + async ( + _url: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + const body = JSON.parse((init as RequestInit).body as string); + sentStatuses.push(body.newStatus as string); + return { ok: true } as Response; + }, + ); + + const { manager } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + // Append FINALIZED immediately while SUBMITTED is still being sent + manager.reportFinalised(TX_META_ID, true); + await flushPromises(); + + expect(sentStatuses[0]).toBe(QuoteStatusUpdateStatus.Submitted); + expect(sentStatuses[1]).toBe(QuoteStatusUpdateStatus.FinalizedSuccess); + }); + }); + }); + + // ── Persistence ──────────────────────────────────────────────────────────── + + describe('persistence', () => { + it('persists after every state change', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + // enqueue → 1 persist + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + expect(persistDeferredUpdates).toHaveBeenCalledTimes(1); + + await flushPromises(); + + // success → 1 more persist (on #removeEntry) + expect(persistDeferredUpdates).toHaveBeenCalledTimes(2); + }); + + it('persisted record is a plain object (not a Map), safe for Immer', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + + const persisted = persistDeferredUpdates.mock.calls[0][0]; + expect(persisted).not.toBeInstanceOf(Map); + expect(typeof persisted).toBe('object'); + }); + + it('persisted pendingStatuses arrays are independent copies across persist calls', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + // First persist: enqueue with ['SUBMITTED'] + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + const firstPersistStatuses = [ + ...persistDeferredUpdates.mock.calls[0][0][QUEUE_KEY].pendingStatuses, + ]; + + // Append FINALIZED_SUCCESS — triggers a second persist call + manager.reportFinalised(TX_META_ID, true); + const secondPersistStatuses = + persistDeferredUpdates.mock.calls[1][0][QUEUE_KEY].pendingStatuses; + + // First persist captured only ['SUBMITTED'] + expect(firstPersistStatuses).toStrictEqual([ + QuoteStatusUpdateStatus.Submitted, + ]); + // Second persist captured ['SUBMITTED', 'FINALIZED_SUCCESS'] + expect(secondPersistStatuses).toStrictEqual([ + QuoteStatusUpdateStatus.Submitted, + QuoteStatusUpdateStatus.FinalizedSuccess, + ]); + }); + + it('reflects empty record when queue is drained', async () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted(QUOTE_ID, SRC_TX_HASH, TX_META_ID); + await flushPromises(); + + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + + it('reflects multiple entries correctly', () => { + fetchSpy = mockFetchOk(); + const { manager, persistDeferredUpdates } = buildManager(); + + manager.reportSubmitted('q1', '0xhash1', 'meta1'); + manager.reportSubmitted('q2', '0xhash2', 'meta2'); + + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(Object.keys(lastPersisted)).toHaveLength(2); + expect(lastPersisted['q1:0xhash1']).toBeDefined(); + expect(lastPersisted['q2:0xhash2']).toBeDefined(); + }); + + it('restores a live mutable queue from frozen initialDeferredUpdates', async () => { + fetchSpy = mockFetchOk(); + const frozenEntry = Object.freeze( + buildEntry({ + pendingStatuses: Object.freeze([ + QuoteStatusUpdateStatus.Submitted, + ]) as QuoteStatusUpdateStatus[], + }), + ); + + const { persistDeferredUpdates } = buildManager({ + initialDeferredUpdates: { + [QUEUE_KEY]: frozenEntry as DeferredStatusUpdateEntry, + }, + }); + + // Fire the stagger setTimeout(0) and let the send complete + await advanceAndFlush(0); + await flushPromises(); + + // Manager successfully mutated pendingStatuses (shift) without throwing + const lastPersisted = + persistDeferredUpdates.mock.calls[ + persistDeferredUpdates.mock.calls.length - 1 + ][0]; + expect(lastPersisted).toStrictEqual({}); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/quote-status-update-manager.ts b/packages/bridge-status-controller/src/quote-status-update-manager.ts new file mode 100644 index 0000000000..02acca82de --- /dev/null +++ b/packages/bridge-status-controller/src/quote-status-update-manager.ts @@ -0,0 +1,601 @@ +import type { BridgeClientId } from '@metamask/bridge-controller'; +import { getClientHeaders } from '@metamask/bridge-controller'; + +import { + QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES, + QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS, + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS, + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS, + QuoteStatusUpdateErrorType, + QuoteStatusUpdateSendWithRetryResult, + QuoteStatusUpdateStatus, +} from './constants'; +import { QuoteStatusUpdateError } from './errors'; +import type { + BridgeStatusControllerMessenger, + DeferredStatusUpdateEntry, + QuoteStatusUpdateResponse, +} from './types'; +import { getJwt } from './utils/authentication'; +import { sleep } from './utils/helpers'; + +/** + * Handles reporting quote status updates (SUBMITTED / FINALISED) to the + * Bridge API via a single persisted deferred retry queue. + * + * Each entry holds a FIFO queue of status strings (`pendingStatuses`) to + * send in order. {@link reportSubmitted} creates an entry with `SUBMITTED` + * as the first pending status. {@link reportFinalised} appends the final + * outcome (`FINALIZED_SUCCESS` or `FINALIZED_FAILURE`). Processing sends + * `pendingStatuses[0]`, and on success shifts it off. The entry is deleted + * once the queue is empty. + * + * Entries are retried every {@link QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS} + * for up to {@link QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS}. + * + * **Note:** Non-retryable errors such as `SVM_TRADE_DESERIALIZE_FAILED` are evicted + * from the retry logic since they cannot be mitigated on the client side. + * In the future, we might consider including these errors in the retry mechanism + * with a {@link QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS} expiration, but for now, + * we exclude them to prevent excessive network calls for errors that are expected to + * fail due to backend issues. + * + * **Note:** If we fail to finalize after {@link QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES} attempts, + * we currently evict the request. However, we may want to consider enqueueing these cases and + * allowing retries every 30 minutes for up to 12 hours, similar to how we currently handle + * "submitted" statuses. + */ +export class QuoteStatusUpdateManager { + readonly #messenger: BridgeStatusControllerMessenger; + + readonly #clientId: BridgeClientId; + + readonly #apiBaseUrl: string; + + /** + * In-memory deferred quote status updates keyed by `${quoteId}:${srcTxHash}`. + * Each value holds a FIFO `pendingStatuses` list and metadata; the map is + * cloned into controller state via {@link #persistToState} whenever it changes. + */ + readonly #deferredRetryQueue: Map; + + /** + * Writes the full deferred-queue snapshot to {@link BridgeStatusController} + * state as `deferredStatusUpdates` (replacing the prior record). Injected by + * the controller constructor; invoked from {@link #persistToState} after + * cloning the map so Immer does not freeze live entries. + * + * @param updates - Plain record mirroring {@link #deferredRetryQueue} keys and entries. + */ + readonly #persistDeferredUpdates: ( + updates: Record, + ) => void; + + /** + * Optional callback invoked whenever an entry is evicted due to a + * non-recoverable condition (expired retry window, exhausted finalization + * retries, or a permanently non-retryable API error). Receives the eviction + * reason as an `Error` so callers can forward it to error-reporting services. + */ + readonly #onError: ((error: QuoteStatusUpdateError) => void) | undefined; + + /** + * Optional callback that returns whether quote-status update reporting is + * currently enabled. Called lazily at each decision point so that toggling a + * remote feature flag takes effect immediately without re-instantiation. + */ + readonly #isEnabled: (() => boolean) | undefined; + + /** + * Tracks which keys have an in-flight #processSingleEntry call to prevent + * concurrent processing of the same entry. + */ + readonly #inFlight = new Set(); + + #retryIntervalId: ReturnType | null = null; + + constructor({ + messenger, + clientId, + apiBaseUrl, + initialDeferredUpdates, + persistDeferredUpdates, + onError, + isEnabled, + }: { + messenger: BridgeStatusControllerMessenger; + clientId: BridgeClientId; + apiBaseUrl: string; + initialDeferredUpdates?: Record; + persistDeferredUpdates: ( + updates: Record, + ) => void; + onError?: (error: QuoteStatusUpdateError) => void; + isEnabled?: () => boolean; + }) { + this.#messenger = messenger; + this.#clientId = clientId; + this.#apiBaseUrl = apiBaseUrl; + this.#persistDeferredUpdates = persistDeferredUpdates; + this.#onError = onError; + this.#isEnabled = isEnabled; + + this.#deferredRetryQueue = new Map( + Object.entries(initialDeferredUpdates ?? {}).map(([key, entry]) => [ + key, + // Entries from `initialDeferredUpdates` come from Immer-managed controller + // state, which deep-freezes all nested objects. Cloning each entry here + // ensures the in-memory queue holds mutable objects so that mutations + // work correctly without throwing a "read only property" error. + { ...entry, pendingStatuses: [...entry.pendingStatuses] }, + ]), + ); + + this.#dropExpiredEntries(); + + // If there are items to be processed, start the poller and + // immediately attempt to process all entries (don't wait for + // the first interval tick). + if (this.#deferredRetryQueue.size > 0) { + this.#ensureRetryTimerRunning(); + let delay = 0; + for (const key of this.#deferredRetryQueue.keys()) { + setTimeout(() => this.#processSingleEntry(key), delay); + delay += 125; + } + } + } + + /** + * Enqueues a SUBMITTED status report and immediately attempts to send it. + * + * @param quoteId - The quote id + * @param srcTxHash - The source transaction hash + * @param txMetaId - Optional transaction meta id for finalization tracking + */ + reportSubmitted(quoteId: string, srcTxHash: string, txMetaId?: string): void { + if (!this.#isEnabled?.()) { + return; + } + const key = this.#enqueue({ + quoteId, + srcTxHash, + txMetaId, + pendingStatuses: [QuoteStatusUpdateStatus.Submitted], + }); + this.#processSingleEntry(key); + } + + /** + * Appends the final outcome to the entry's pending statuses queue. + * + * If the entry is not currently in-flight, triggers processing + * immediately. Otherwise the outcome will be picked up once the + * current in-flight call completes and the retry loop continues. + * + * @param txMetaId - The transaction meta id + * @param success - Whether the transaction succeeded + */ + reportFinalised(txMetaId: string, success: boolean): void { + if (!this.#isEnabled?.()) { + return; + } + const matchingKey = this.#findKeyByTxMetaId(txMetaId); + if (!matchingKey) { + return; + } + + const entry = this.#deferredRetryQueue.get( + matchingKey, + ) as DeferredStatusUpdateEntry; + + entry.pendingStatuses.push( + success + ? QuoteStatusUpdateStatus.FinalizedSuccess + : QuoteStatusUpdateStatus.FinalizedFailed, + ); + this.#persistToState(); + + if (!this.#inFlight.has(matchingKey)) { + this.#processSingleEntry(matchingKey); + } + } + + /** + * Stops the deferred retry timer and clears the in-memory queue. + * Does not persist, the caller is responsible for resetting state. + */ + destroy(): void { + this.#stopRetryTimer(); + this.#deferredRetryQueue.clear(); + this.#inFlight.clear(); + } + + #enqueue( + entry: Omit, + ): string { + const key = `${entry.quoteId}:${entry.srcTxHash}`; + const now = Date.now(); + this.#deferredRetryQueue.set(key, { + ...entry, + createdAt: now, + lastAttemptAt: now, + }); + this.#persistToState(); + this.#ensureRetryTimerRunning(); + return key; + } + + #findKeyByTxMetaId(txMetaId: string): string | undefined { + for (const [key, entry] of this.#deferredRetryQueue) { + if (entry.txMetaId === txMetaId) { + return key; + } + } + return undefined; + } + + /** + * Sends the next pending status for one deferred entry: runs + * {@link #sendWithRetry} for `pendingStatuses[0]`, then applies the + * {@link QuoteStatusUpdateSendWithRetryResult} (shift queue on success, persist and keep for + * deferred interval on retryable, or no-op when already handled inside + * {@link #sendWithRetry}). Skips if the row is gone, already in flight, + * has no pending statuses, or exceeded the max retry lifetime. + * + * @param key - Deferred map key (`${quoteId}:${srcTxHash}`). + */ + #processSingleEntry(key: string): void { + // Skip processing if the feature has been disabled after this entry was + // already scheduled. The entry remains in state and will be retried the + // next time the feature is re-enabled (e.g. on service-worker restart). + if (!this.#isEnabled?.()) { + return; + } + + // Row may have been removed by another path or never existed for this key. + const entry = this.#deferredRetryQueue.get(key); + // Do not start a second in-flight send for the same key (#sendWithRetry is async). + if (!entry || this.#inFlight.has(key)) { + return; + } + + // Defensive cleanup: an empty FIFO should not stay in the map. + if (entry.pendingStatuses.length === 0) { + this.#removeEntry(key); + return; + } + + // Stop retrying stale bridge submissions after the configured window. + if ( + Date.now() - entry.createdAt > + QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS + ) { + this.#onError?.( + new QuoteStatusUpdateError( + `evicting deferred retry cause it exceeded the expiration window`, + { + quoteId: entry.quoteId, + }, + ), + ); + this.#removeEntry(key); + return; + } + + // Mutex: mark before awaiting so concurrent callers bail at the guard above. + this.#inFlight.add(key); + + // FIFO: always POST the head of the queue (SUBMITTED before finalization, etc.). + const currentStatus = entry.pendingStatuses[0]; + + this.#sendWithRetry(key, entry, currentStatus) + .then((result) => { + // Finalization / eviction already ran inside #sendWithRetry; state is current. + if (result === QuoteStatusUpdateSendWithRetryResult.Handled) { + return undefined; + } + + if (result === QuoteStatusUpdateSendWithRetryResult.Success) { + // API accepted this status; move on to the next pending value if any. + entry.pendingStatuses.shift(); + + if (entry.pendingStatuses.length > 0) { + // Persist the shortened queue, release the lock, then continue same key. + this.#persistToState(); + this.#inFlight.delete(key); + this.#processSingleEntry(key); + } else { + // Queue drained for this quote+hash; drop the row and stop timers if idle. + this.#inFlight.delete(key); + this.#removeEntry(key); + } + return undefined; + } + + // Retryable — immediate retries exhausted, keep in deferred queue + // Release mutex so the 30m poller (or constructor stagger) can retry later. + this.#inFlight.delete(key); + // Touch lastAttempt for observability / future ordering; persist survives restart. + entry.lastAttemptAt = Date.now(); + this.#persistToState(); + return undefined; + }) + .catch(() => { + // Network / unexpected failures — keep entry for deferred retry + // Same as retryable path: clear in-flight, bump timestamp, persist for resume. + this.#inFlight.delete(key); + entry.lastAttemptAt = Date.now(); + this.#persistToState(); + }); + } + + /** + * Attempts to send the corrected finalization status with up to + * {@link QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES} retries. + * On failure, the entry is evicted. + * + * @param key - The deferred queue key + * @param entry - The deferred status update entry + */ + async #attemptFinalizationWithRetries( + key: string, + entry: DeferredStatusUpdateEntry, + ): Promise { + const finalizationStatus = entry.pendingStatuses[0]; + + for ( + let attempt = 0; + attempt <= QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES; + attempt++ + ) { + if (attempt > 0) { + await sleep(QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS); + } + + try { + const response = await this.#updateQuoteStatus( + entry.quoteId, + entry.srcTxHash, + finalizationStatus, + ); + + // Request succeeded, remove entry from queue. + if (response === undefined) { + this.#removeEntry(key); + return; + } + } catch { + // Network error, continue retrying + } + } + + this.#removeEntry(key); + this.#onError?.( + new QuoteStatusUpdateError( + `evicting due to finalization retries exhausted for quote`, + { + quoteId: entry.quoteId, + }, + ), + ); + } + + /** + * Sends a status update, immediately retrying up to + * {@link QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES} times when the + * response is {@link QuoteStatusUpdateErrorType.ConcurrentUpdate} or + * {@link QuoteStatusUpdateErrorType.TransactionNotIndexed}. + * + * If a non-retryable actionable error is received mid-retry (e.g. + * {@link QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch} or + * {@link QuoteStatusUpdateErrorType.InvalidStatusTransaction}), the + * retry loop is aborted and finalization is attempted immediately. + * + * Returns one of three {@link QuoteStatusUpdateSendWithRetryResult} outcomes: + * - {@link QuoteStatusUpdateSendWithRetryResult.Success} — 2xx accepted + * - {@link QuoteStatusUpdateSendWithRetryResult.Retryable} — immediate retries exhausted, entry stays in deferred queue + * - {@link QuoteStatusUpdateSendWithRetryResult.Handled} — finalization or eviction was performed internally + * + * Throws only on network-level / unexpected failures (no response body). + * + * @param key - The deferred queue key + * @param entry - The deferred status update entry + * @param status - The status string to send + * @returns The outcome of the send attempt + */ + async #sendWithRetry( + key: string, + entry: DeferredStatusUpdateEntry, + status: QuoteStatusUpdateStatus, + ): Promise { + const retryableTypes: Set = new Set([ + QuoteStatusUpdateErrorType.ConcurrentUpdate, + QuoteStatusUpdateErrorType.TransactionNotIndexed, + ]); + + for ( + let attempt = 0; + attempt <= QUOTE_STATUS_UPDATE_IMMEDIATE_MAX_RETRIES; + attempt++ + ) { + if (attempt > 0) { + await sleep(QUOTE_STATUS_UPDATE_IMMEDIATE_RETRY_DELAY_MS); + } + + const response = await this.#updateQuoteStatus( + entry.quoteId, + entry.srcTxHash, + status, + ); + + if (response === undefined) { + return QuoteStatusUpdateSendWithRetryResult.Success; + } + + if (!retryableTypes.has(response.type)) { + await this.#handleNonRetryableError(key, entry, response); + return QuoteStatusUpdateSendWithRetryResult.Handled; + } + } + + return QuoteStatusUpdateSendWithRetryResult.Retryable; + } + + /** + * Handles a non-retryable error response by either attempting finalization + * with the corrected status or evicting the entry. + * + * @param key - The deferred queue key + * @param entry - The deferred status update entry + * @param response - The non-retryable error response + */ + async #handleNonRetryableError( + key: string, + entry: DeferredStatusUpdateEntry, + response: QuoteStatusUpdateResponse, + ): Promise { + const { type } = response; + + if ( + type === QuoteStatusUpdateErrorType.InvalidStatusTransaction || + type === QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch + ) { + const finalizationStatus = response.currentStatus; + entry.pendingStatuses = [finalizationStatus]; + this.#persistToState(); + this.#inFlight.delete(key); + await this.#attemptFinalizationWithRetries(key, entry); + return; + } + + // Any other error type — do not retry, evict + this.#inFlight.delete(key); + this.#removeEntry(key); + this.#onError?.( + new QuoteStatusUpdateError(`evicting due to non-retryable error`, { + quoteId: entry.quoteId, + errorType: type, + }), + ); + } + + #removeEntry(key: string): void { + this.#deferredRetryQueue.delete(key); + this.#persistToState(); + if (this.#deferredRetryQueue.size === 0) { + this.#stopRetryTimer(); + } + } + + #ensureRetryTimerRunning(): void { + if (this.#retryIntervalId !== null) { + return; + } + this.#retryIntervalId = setInterval( + () => this.#processDeferredRetries(), + QUOTE_STATUS_UPDATE_RETRY_INTERVAL_MS, + ); + } + + #stopRetryTimer(): void { + if (this.#retryIntervalId !== null) { + clearInterval(this.#retryIntervalId); + this.#retryIntervalId = null; + } + } + + #processDeferredRetries(): void { + this.#dropExpiredEntries(); + + if (this.#deferredRetryQueue.size === 0) { + this.#stopRetryTimer(); + return; + } + + for (const key of this.#deferredRetryQueue.keys()) { + this.#processSingleEntry(key); + } + } + + #dropExpiredEntries(): void { + const now = Date.now(); + let changed = false; + + for (const [key, entry] of this.#deferredRetryQueue) { + if (now - entry.createdAt > QUOTE_STATUS_UPDATE_RETRY_MAX_LIFETIME_MS) { + this.#deferredRetryQueue.delete(key); + this.#onError?.( + new QuoteStatusUpdateError( + `evicting deferred retry cause it exceeded the expiration window`, + { + quoteId: entry.quoteId, + }, + ), + ); + changed = true; + } + } + + if (changed) { + this.#persistToState(); + } + } + + /** + * Clones entries before persisting so the controller's state management + * (Immer) does not freeze the in-memory Map objects. + */ + #persistToState(): void { + const cloned: Record = {}; + for (const [key, entry] of this.#deferredRetryQueue) { + cloned[key] = { ...entry, pendingStatuses: [...entry.pendingStatuses] }; + } + this.#persistDeferredUpdates(cloned); + } + + /** + * Calls the Bridge API updateStatus endpoint. + * + * Returns `undefined` on 2xx, or the parsed error response body on non-2xx + * so callers can branch on the typed error. + * + * @param quoteId - The quote id + * @param srcTxHash - The source transaction hash + * @param newStatus - The new status to report + * @returns The parsed error response, or undefined on success + */ + readonly #updateQuoteStatus = async ( + quoteId: string, + srcTxHash: string, + newStatus: QuoteStatusUpdateStatus, + ): Promise => { + // This method uses `globalThis.fetch` and reads the raw + // `Response` (including JSON on non-2xx). Wrappers like `handleFetch` that + // throw on non-2xx would prevent typed error handling in callers. + const res = await globalThis.fetch( + `${this.#apiBaseUrl}/quote/updateStatus`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...getClientHeaders({ + clientId: this.#clientId, + jwt: await getJwt(this.#messenger), + }), + }, + body: JSON.stringify({ + quoteId, + newStatus, + srcTxHash, + }), + }, + ); + + if (res.ok) { + return undefined; + } + + return (await res.json()) as QuoteStatusUpdateResponse; + }; +} diff --git a/packages/bridge-status-controller/src/types.ts b/packages/bridge-status-controller/src/types.ts index 05222f513d..4517f18466 100644 --- a/packages/bridge-status-controller/src/types.ts +++ b/packages/bridge-status-controller/src/types.ts @@ -31,13 +31,18 @@ import type { TransactionControllerGetStateAction, TransactionControllerIsAtomicBatchSupportedAction, TransactionControllerTransactionStatusUpdatedEvent, + TransactionControllerTransactionSubmittedEvent, TransactionControllerUpdateTransactionAction, TransactionMeta, } from '@metamask/transaction-controller'; import type { CaipAssetType } from '@metamask/utils'; import type { BridgeStatusControllerMethodActions } from './bridge-status-controller-method-action-types'; -import { BRIDGE_STATUS_CONTROLLER_NAME } from './constants'; +import { + BRIDGE_STATUS_CONTROLLER_NAME, + QuoteStatusUpdateErrorType, + QuoteStatusUpdateStatus, +} from './constants'; import type { StatusResponseSchema } from './utils/validators'; // All fields need to be types not interfaces, same with their children fields @@ -115,6 +120,7 @@ export type BridgeHistoryItem = { originalTransactionId?: string; // Keep original transaction ID for intent transactions batchId?: string; quote: Quote; + quoteId: string; status: StatusResponse; startTime: number; // timestamp in ms estimatedProcessingTimeInSeconds: number; @@ -264,8 +270,18 @@ export type StartPollingForBridgeTxStatusArgsSerialized = Omit< export type SourceChainTxMetaId = string; +export type DeferredStatusUpdateEntry = { + quoteId: string; + srcTxHash: string; + pendingStatuses: QuoteStatusUpdateStatus[]; + createdAt: number; + lastAttemptAt: number; + txMetaId?: string; +}; + export type BridgeStatusControllerState = { txHistory: Record; + deferredStatusUpdates: Record; }; // Actions @@ -320,7 +336,9 @@ type AllowedActions = /** * The external events available to the BridgeStatusController. */ -type AllowedEvents = TransactionControllerTransactionStatusUpdatedEvent; +type AllowedEvents = + | TransactionControllerTransactionStatusUpdatedEvent + | TransactionControllerTransactionSubmittedEvent; /** * The messenger for the BridgeStatusController. @@ -330,3 +348,23 @@ export type BridgeStatusControllerMessenger = Messenger< BridgeStatusControllerActions | AllowedActions, BridgeStatusControllerEvents | AllowedEvents >; + +export type QuoteStatusUpdateResponse = + | { + statusCode: number; + message: string; + type: + | QuoteStatusUpdateErrorType.InvalidStatusTransaction + | QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch; + currentStatus: QuoteStatusUpdateStatus; + newStatus: QuoteStatusUpdateStatus; + } + | { + statusCode: number; + message: string; + type: Exclude< + QuoteStatusUpdateErrorType, + | QuoteStatusUpdateErrorType.QuoteStatusOnChainMismatch + | QuoteStatusUpdateErrorType.InvalidStatusTransaction + >; + }; diff --git a/packages/bridge-status-controller/src/utils/helpers.ts b/packages/bridge-status-controller/src/utils/helpers.ts new file mode 100644 index 0000000000..45af4f76de --- /dev/null +++ b/packages/bridge-status-controller/src/utils/helpers.ts @@ -0,0 +1,2 @@ +export const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); diff --git a/packages/bridge-status-controller/src/utils/history.ts b/packages/bridge-status-controller/src/utils/history.ts index 0f15aef511..d51e3622f3 100644 --- a/packages/bridge-status-controller/src/utils/history.ts +++ b/packages/bridge-status-controller/src/utils/history.ts @@ -17,7 +17,7 @@ import { getMaxPendingHistoryItemAgeMs } from './feature-flags'; export const rekeyHistoryItemInState = ( state: BridgeStatusControllerState, actionId: string, - txMeta: { id: string; hash?: string }, + txMeta: { id: string; hash?: string; batchId?: string }, ): boolean => { const historyItem = state.txHistory[actionId]; if (!historyItem) { @@ -28,6 +28,9 @@ export const rekeyHistoryItemInState = ( ...historyItem, txMetaId: txMeta.id, originalTransactionId: historyItem.originalTransactionId ?? txMeta.id, + // Propagate batchId from the submitted txMeta if the pre-submission item + // did not yet have one (it was created before the TX existed). + batchId: historyItem.batchId ?? txMeta.batchId, status: { ...historyItem.status, srcChain: { @@ -155,6 +158,7 @@ export const getInitialHistoryItem = ( originalTransactionId: originalTransactionId ?? bridgeTxMeta?.id, // Keep original for intent transactions batchId: bridgeTxMeta?.batchId, quote: quoteResponse.quote, + quoteId: quoteResponse.quoteId, startTime, estimatedProcessingTimeInSeconds: quoteResponse.estimatedProcessingTimeInSeconds,