diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index f29b0485bf9..2f9d42287de 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -29,6 +29,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add support for Bitcoin bridge transactions ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Handle Bitcoin PSBT (Partially Signed Bitcoin Transaction) format in trade data + - Add `BitcoinTradeDataSchema` and `BitcoinQuoteResponseSchema` validators + - Support Bitcoin chain ID (`ChainId.BTC = 20000000000001`) and CAIP format (`bip122:000000000019d6689c085ae165831e93`) +- Export `isNonEvmChainId` utility function to check for non-EVM chains (Solana, Bitcoin) ([#6454](https://github.com/MetaMask/core/pull/6454)) - Add `selectDefaultSlippagePercentage` that returns the default slippage for a chain and token combination ([#6616](https://github.com/MetaMask/core/pull/6616)) - Return `0.5` if requesting a bridge quote - Return `undefined` (auto) if requesting a Solana swap @@ -38,9 +43,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **BREAKING:** Rename fee handling for non-EVM chains ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Replace `SolanaFees` type with `NonEvmFees` type + - Replace `solanaFeesInLamports` field with `nonEvmFeesInNative` field + - Update `#appendSolanaFees` to `#appendNonEvmFees` to support all non-EVM chains + - The `nonEvmFeesInNative` field stores fees in the smallest units for each chain (lamports for Solana, satoshis for Bitcoin) +- Update Snap methods to use new unified interface for non-EVM chains ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Replace `getFeeForTransaction` with `computeFee` method that returns fees in native token units + - Update fee calculation to handle different unit conversions per chain + - Support fee computation for Bitcoin and Solana chains +- Update quote validation to support Bitcoin-specific trade data format ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Add separate validation for Bitcoin quotes that include `unsignedPsbtBase64` field +- Update selectors and utilities to use `isNonEvmChainId` instead of `isSolanaChainId` for generic non-EVM handling ([#6454](https://github.com/MetaMask/core/pull/6454)) - Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) - Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) +### Removed + +- **BREAKING:** Remove deprecated `SolanaFees` type - use `NonEvmFees` type instead ([#6454](https://github.com/MetaMask/core/pull/6454)) +- **BREAKING:** Remove `solanaFeesInLamports` field from quotes - use `nonEvmFeesInNative` field instead ([#6454](https://github.com/MetaMask/core/pull/6454)) + ## [43.0.0] ### Added diff --git a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap index 94575ecba68..fbaccad9f39 100644 --- a/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap +++ b/packages/bridge-controller/src/__snapshots__/bridge-controller.test.ts.snap @@ -558,11 +558,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -573,11 +576,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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", }, @@ -614,11 +620,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -629,11 +638,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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", }, @@ -670,11 +682,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -685,11 +700,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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", }, @@ -726,11 +744,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -741,11 +762,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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", }, @@ -1013,11 +1037,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -1028,11 +1055,14 @@ Array [ Array [ "SnapController:handleRequest", Object { - "handler": "onRpcRequest", + "handler": "onClientRequest", "origin": "metamask", "request": Object { - "method": "getFeeForTransaction", + "id": "test-uuid-1234", + "jsonrpc": "2.0", + "method": "computeFee", "params": Object { + "accountId": "account1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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", }, diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 4cd8ad60725..c083d3ad331 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -3,6 +3,7 @@ import { Contract } from '@ethersproject/contracts'; import { deriveStateFromMetadata } from '@metamask/base-controller'; import { + BtcScope, EthAccountType, EthScope, SolAccountType, @@ -589,6 +590,26 @@ describe('BridgeController', function () { resolve('5000'); }, 200); } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: '0.000000014', // 14 lamports in SOL + fungible: true, + }, + }, + ]); + }, 100); + } return setTimeout(() => { resolve({ value: '14' }); }, 100); @@ -669,9 +690,15 @@ describe('BridgeController', function () { minimumBalanceForRentExemptionInLamports: '5000', quotes: mockBridgeQuotesSolErc20.map((quote) => ({ ...quote, - solanaFeesInLamports: '14', + nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: quoteParams, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: 1, + quotesInitialLoadTime: expect.any(Number), + quotesLastFetched: expect.any(Number), }), ); expect(consoleErrorSpy).not.toHaveBeenCalled(); @@ -725,9 +752,15 @@ describe('BridgeController', function () { minimumBalanceForRentExemptionInLamports: '5000', quotes: mockBridgeQuotesSolErc20.map((quote) => ({ ...quote, - solanaFeesInLamports: '14', + nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: quoteParams, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: expect.any(Number), + quotesInitialLoadTime: expect.any(Number), + quotesLastFetched: expect.any(Number), }), ); expect(consoleErrorSpy).not.toHaveBeenCalled(); @@ -755,9 +788,15 @@ describe('BridgeController', function () { minimumBalanceForRentExemptionInLamports: '0', quotes: mockBridgeQuotesSolErc20.map((quote) => ({ ...quote, - solanaFeesInLamports: '14', + nonEvmFeesInNative: '0.000000014', })), quotesLoadingStatus: RequestStatus.FETCHED, + quoteRequest: { ...quoteParams, srcTokenAmount: '11111' }, + quoteFetchError: null, + assetExchangeRates: {}, + quotesRefreshCount: expect.any(Number), + quotesInitialLoadTime: expect.any(Number), + quotesLastFetched: expect.any(Number), }), ); @@ -1562,7 +1601,7 @@ describe('BridgeController', function () { mockBridgeQuotesSolErc20 as unknown as QuoteResponse[], [], 2, - '5000', + '0.000005000', // SOL amount (5000 lamports) '300', ], [ @@ -1679,6 +1718,26 @@ describe('BridgeController', function () { resolve(expectedMinBalance); }, 200); } + if ( + (params as { handler: string })?.handler === + 'onClientRequest' && + (params as { request?: { method: string } })?.request + ?.method === 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'SOL', + type: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp/token:11111111111111111111111111111111', + amount: expectedFees || '0', + fungible: true, + }, + }, + ]); + }, 100); + } return setTimeout(() => { resolve({ value: expectedFees }); }, 100); @@ -1752,9 +1811,9 @@ describe('BridgeController', function () { }), ); - // Verify Solana fees + // Verify non-EVM fees quotes.forEach((quote) => { - expect(quote.solanaFeesInLamports).toBe( + expect(quote.nonEvmFeesInNative).toBe( isSolanaChainId(quote.quote.srcChainId) ? expectedFees : undefined, ); }); @@ -1781,6 +1840,121 @@ describe('BridgeController', function () { }, ); + it('should handle BTC chain fees correctly', async () => { + jest.useFakeTimers(); + // Use the actual Solana mock which already has string trade type + const btcQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => ({ + ...quote, + quote: { + ...quote.quote, + srcChainId: ChainId.BTC, + }, + })) as unknown as QuoteResponse[]; + + messengerMock.call.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'AccountsController:getSelectedMultichainAccount') { + return { + type: 'btc:p2wpkh', + id: 'btc-account-1', + scopes: [BtcScope.Mainnet], + methods: [], + address: 'bc1q...', + metadata: { + name: 'BTC Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'btc-snap-id', + name: 'BTC Snap', + }, + }, + } as never; + } + + if (actionType === 'SnapController:handleRequest') { + return new Promise((resolve) => { + if ( + (params as { handler: string })?.handler === 'onClientRequest' && + (params as { request?: { method: string } })?.request?.method === + 'computeFee' + ) { + return setTimeout(() => { + resolve([ + { + type: 'base', + asset: { + unit: 'BTC', + type: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + amount: '0.00005', // BTC fee + fungible: true, + }, + }, + ]); + }, 100); + } + return setTimeout(() => { + resolve('5000'); + }, 200); + }); + } + + return { + provider: jest.fn() as never, + selectedNetworkClientId: 'selectedNetworkClientId', + } as never; + }, + ); + + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: btcQuoteResponse, + validationFailures: [], + }); + + const quoteParams = { + srcChainId: ChainId.BTC.toString(), + destChainId: '1', + srcTokenAddress: 'NATIVE', + destTokenAddress: '0x0000000000000000000000000000000000000000', + srcTokenAmount: '100000', // satoshis + walletAddress: 'bc1q...', + destWalletAddress: '0x5342', + slippage: 0.5, + }; + + await bridgeController.updateBridgeQuoteRequestParams( + quoteParams, + metricsContext, + ); + + // Wait for polling to start + jest.advanceTimersByTime(201); + await flushPromises(); + + // Wait for fetch to trigger + jest.advanceTimersByTime(295); + await flushPromises(); + + // Wait for fetch to complete + jest.advanceTimersByTime(2601); + await flushPromises(); + + // Final wait for fee calculation + jest.advanceTimersByTime(100); + await flushPromises(); + + const { quotes } = bridgeController.state; + expect(quotes).toHaveLength(2); // mockBridgeQuotesSolErc20 has 2 quotes + expect(quotes[0].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + expect(quotes[1].nonEvmFeesInNative).toBe('0.00005'); // BTC fee as-is + }); + describe('trackUnifiedSwapBridgeEvent client-side calls', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/bridge-controller/src/bridge-controller.ts b/packages/bridge-controller/src/bridge-controller.ts index ba332fdc6fc..c653faeabb0 100644 --- a/packages/bridge-controller/src/bridge-controller.ts +++ b/packages/bridge-controller/src/bridge-controller.ts @@ -25,7 +25,7 @@ import type { QuoteRequest } from './types'; import { type L1GasFees, type GenericQuoteRequest, - type SolanaFees, + type NonEvmFees, type QuoteResponse, type TxData, type BridgeControllerState, @@ -38,6 +38,7 @@ import { hasSufficientBalance } from './utils/balance'; import { getDefaultBridgeControllerState, isCrossChain, + isNonEvmChainId, isSolanaChainId, sumHexes, } from './utils/bridge'; @@ -71,7 +72,7 @@ import type { import { type CrossChainSwapsEventProperties } from './utils/metrics/types'; import { isValidQuoteRequest } from './utils/quote'; import { - getFeeForTransactionRequest, + computeFeeRequest, getMinimumBalanceForRentExemptionRequest, } from './utils/snaps'; import { FeatureId } from './utils/validators'; @@ -310,7 +311,7 @@ export class BridgeController extends StaticIntervalPollingController { @@ -482,6 +483,12 @@ export class BridgeController extends StaticIntervalPollingController { const walletAddress = this.#getMultichainSelectedAccount()?.address; + + // Only check balance for EVM chains + if (isNonEvmChainId(quoteRequest.srcChainId)) { + return true; + } + const srcChainIdInHex = formatChainIdToHex(quoteRequest.srcChainId); const provider = this.#getSelectedNetworkClient()?.provider; const normalizedSrcTokenAddress = formatAddressToCaipReference( @@ -745,51 +752,75 @@ export class BridgeController extends StaticIntervalPollingController => { - // Return early if some of the quotes are not for solana + ): Promise<(QuoteResponse & NonEvmFees)[] | undefined> => { if ( - quotes.some(({ quote: { srcChainId } }) => !isSolanaChainId(srcChainId)) + quotes.some(({ quote: { srcChainId } }) => !isNonEvmChainId(srcChainId)) ) { return undefined; } - const solanaFeePromises = Promise.allSettled( + const nonEvmFeePromises = Promise.allSettled( quotes.map(async (quoteResponse) => { - const { trade } = quoteResponse; + const { trade, quote } = quoteResponse; const selectedAccount = this.#getMultichainSelectedAccount(); if (selectedAccount?.metadata?.snap?.id && typeof trade === 'string') { - const { value: fees } = (await this.messagingSystem.call( + const scope = formatChainIdToCaip(quote.srcChainId); + + const response = (await this.messagingSystem.call( 'SnapController:handleRequest', - getFeeForTransactionRequest( + computeFeeRequest( selectedAccount.metadata.snap?.id, trade, + selectedAccount.id, + scope, ), - )) as { value: string }; + )) as { + type: 'base' | 'priority'; + asset: { + unit: string; + type: string; + amount: string; + fungible: true; + }; + }[]; + + const baseFee = response?.find((fee) => fee.type === 'base'); + // Store fees in native units as returned by the snap (e.g., SOL, BTC) + const feeInNative = baseFee?.asset?.amount || '0'; return { ...quoteResponse, - solanaFeesInLamports: fees, + nonEvmFeesInNative: feeInNative, }; } return quoteResponse; }), ); - const quotesWithSolanaFees = (await solanaFeePromises).reduce< - (QuoteResponse & SolanaFees)[] + const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce< + (QuoteResponse & NonEvmFees)[] >((acc, result) => { if (result.status === 'fulfilled' && result.value) { acc.push(result.value); } else if (result.status === 'rejected') { - console.error('Error calculating solana fees for quote', result.reason); + console.error( + 'Error calculating non-EVM fees for quote', + result.reason, + ); } return acc; }, []); - return quotesWithSolanaFees; + return quotesWithNonEvmFees; }; #getMultichainSelectedAccount() { diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index 53f9ee0fa27..2e0017cd77d 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -26,7 +26,7 @@ export { export type { ChainConfiguration, L1GasFees, - SolanaFees, + NonEvmFees, QuoteMetadata, GasMultiplierByChainId, FeatureFlagResponse, @@ -105,6 +105,7 @@ export { isNativeAddress, isSolanaChainId, isBitcoinChainId, + isNonEvmChainId, getNativeAssetForChainId, getDefaultBridgeControllerState, isCrossChain, diff --git a/packages/bridge-controller/src/selectors.test.ts b/packages/bridge-controller/src/selectors.test.ts index f022d3c558d..e46d2e843a8 100644 --- a/packages/bridge-controller/src/selectors.test.ts +++ b/packages/bridge-controller/src/selectors.test.ts @@ -11,6 +11,7 @@ import { selectBridgeQuotes, selectIsQuoteExpired, selectBridgeFeatureFlags, + selectMinimumBalanceForRentExemptionInSOL, selectDefaultSlippagePercentage, } from './selectors'; import type { BridgeAsset, QuoteResponse } from './types'; @@ -1115,6 +1116,46 @@ describe('Bridge Selectors', () => { }); }); + describe('selectMinimumBalanceForRentExemptionInSOL', () => { + it('should convert lamports to SOL', () => { + const state = { + minimumBalanceForRentExemptionInLamports: '1000000000', // 1 SOL + } as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('1'); + }); + + it('should handle undefined minimumBalanceForRentExemptionInLamports', () => { + const state = {} as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0'); + }); + + it('should handle null minimumBalanceForRentExemptionInLamports', () => { + const state = { + minimumBalanceForRentExemptionInLamports: null, + } as unknown as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0'); + }); + + it('should handle fractional SOL amounts', () => { + const state = { + minimumBalanceForRentExemptionInLamports: '500000000', // 0.5 SOL + } as BridgeAppState; + + const result = selectMinimumBalanceForRentExemptionInSOL(state); + + expect(result).toBe('0.5'); + }); + }); + describe('selectDefaultSlippagePercentage', () => { const mockValidBridgeConfig = { minimumVersion: '0.0.0', diff --git a/packages/bridge-controller/src/selectors.ts b/packages/bridge-controller/src/selectors.ts index e1182abbef9..a4e0e02c33e 100644 --- a/packages/bridge-controller/src/selectors.ts +++ b/packages/bridge-controller/src/selectors.ts @@ -26,7 +26,7 @@ import { RequestStatus, SortOrder } from './types'; import { getNativeAssetForChainId, isNativeAddress, - isSolanaChainId, + isNonEvmChainId, } from './utils/bridge'; import { formatAddressToAssetId, @@ -41,7 +41,7 @@ import { calcIncludedTxFees, calcRelayerFee, calcSentAmount, - calcSolanaTotalNetworkFee, + calcNonEvmTotalNetworkFee, calcSwapRate, calcToAmount, calcTotalEstimatedNetworkFee, @@ -140,8 +140,8 @@ const getExchangeRateByChainIdAndAddress = ( if (bridgeControllerRate?.exchangeRate) { return bridgeControllerRate; } - // If the chain is a Solana chain, use the conversion rate from the multichain assets controller - if (isSolanaChainId(chainId)) { + // If the chain is a non-EVM chain, use the conversion rate from the multichain assets controller + if (isNonEvmChainId(chainId)) { const multichainAssetExchangeRate = conversionRates?.[assetId]; if (multichainAssetExchangeRate) { return { @@ -164,22 +164,24 @@ const getExchangeRateByChainIdAndAddress = ( return {}; } // If the chain is an EVM chain and the asset is not the native asset, use the conversion rate from the token rates controller - const evmTokenExchangeRates = marketData?.[formatChainIdToHex(chainId)]; - const evmTokenExchangeRateForAddress = isStrictHexString(address) - ? evmTokenExchangeRates?.[address] - : null; - const nativeCurrencyRate = evmTokenExchangeRateForAddress - ? currencyRates[evmTokenExchangeRateForAddress?.currency] - : undefined; - if (evmTokenExchangeRateForAddress && nativeCurrencyRate) { - return { - exchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) - .multipliedBy(nativeCurrencyRate.conversionRate ?? 0) - .toString(), - usdExchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) - .multipliedBy(nativeCurrencyRate.usdConversionRate ?? 0) - .toString(), - }; + if (!isNonEvmChainId(chainId)) { + const evmTokenExchangeRates = marketData?.[formatChainIdToHex(chainId)]; + const evmTokenExchangeRateForAddress = isStrictHexString(address) + ? evmTokenExchangeRates?.[address] + : null; + const nativeCurrencyRate = evmTokenExchangeRateForAddress + ? currencyRates[evmTokenExchangeRateForAddress?.currency] + : undefined; + if (evmTokenExchangeRateForAddress && nativeCurrencyRate) { + return { + exchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) + .multipliedBy(nativeCurrencyRate.conversionRate ?? 0) + .toString(), + usdExchangeRate: new BigNumber(evmTokenExchangeRateForAddress.price) + .multipliedBy(nativeCurrencyRate.usdConversionRate ?? 0) + .toString(), + }; + } } return {}; @@ -287,8 +289,9 @@ const selectBridgeQuotesWithMetadata = createBridgeSelector( relayerFee, gasFee: QuoteMetadata['gasFee']; - if (isSolanaChainId(quote.quote.srcChainId)) { - totalEstimatedNetworkFee = calcSolanaTotalNetworkFee( + if (isNonEvmChainId(quote.quote.srcChainId)) { + // Use the new generic function for all non-EVM chains + totalEstimatedNetworkFee = calcNonEvmTotalNetworkFee( quote, nativeExchangeRate, ); diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index a4cea0bc4a6..32222af7692 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -77,8 +77,8 @@ export type L1GasFees = { l1GasFeesInHexWei?: string; // l1 fees for approval and trade in hex wei, appended by BridgeController.#appendL1GasFees }; -export type SolanaFees = { - solanaFeesInLamports?: string; // solana fees in lamports, appended by BridgeController.#appendSolanaFees +export type NonEvmFees = { + nonEvmFeesInNative?: string; // Non-EVM chain fees in native units (SOL for Solana, BTC for Bitcoin) }; /** @@ -302,7 +302,7 @@ export enum BridgeBackgroundAction { export type BridgeControllerState = { quoteRequest: Partial; - quotes: (QuoteResponse & L1GasFees & SolanaFees)[]; + quotes: (QuoteResponse & L1GasFees & NonEvmFees)[]; quotesInitialLoadTime: number | null; quotesLastFetched: number | null; quotesLoadingStatus: RequestStatus | null; diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts index 4ad2ef92f72..b042da3ba8c 100644 --- a/packages/bridge-controller/src/utils/bridge.test.ts +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -9,6 +9,7 @@ import { isBitcoinChainId, isCrossChain, isEthUsdt, + isNonEvmChainId, isSolanaChainId, isSwapsDefaultTokenAddress, isSwapsDefaultTokenSymbol, @@ -200,6 +201,34 @@ describe('Bridge utils', () => { }); }); + describe('isNonEvmChainId', () => { + it('returns true for Solana chainIds', () => { + expect(isNonEvmChainId(ChainId.SOLANA)).toBe(true); + expect(isNonEvmChainId(SolScope.Mainnet)).toBe(true); + expect(isNonEvmChainId('1151111081099710')).toBe(true); + }); + + it('returns true for Bitcoin chainIds', () => { + expect(isNonEvmChainId(ChainId.BTC)).toBe(true); + expect(isNonEvmChainId(BtcScope.Mainnet)).toBe(true); + expect(isNonEvmChainId('20000000000001')).toBe(true); + }); + + it('returns false for EVM chainIds', () => { + expect(isNonEvmChainId('0x1')).toBe(false); + expect(isNonEvmChainId(1)).toBe(false); + expect(isNonEvmChainId('eip155:1')).toBe(false); + expect(isNonEvmChainId(ChainId.ETH)).toBe(false); + expect(isNonEvmChainId(ChainId.POLYGON)).toBe(false); + }); + + it('returns false for invalid chainIds', () => { + expect(isNonEvmChainId('invalid')).toBe(false); + expect(isNonEvmChainId('test')).toBe(false); + expect(isNonEvmChainId('')).toBe(false); + }); + }); + describe('getNativeAssetForChainId', () => { it('should return native asset for hex chainId', () => { const result = getNativeAssetForChainId('0x1'); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index efa88c24077..954f8ba7962 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -190,6 +190,19 @@ export const isBitcoinChainId = ( return chainId.toString() === ChainId.BTC.toString(); }; +/** + * Checks if a chain ID represents a non-EVM blockchain supported by swaps + * Currently supports Solana and Bitcoin + * + * @param chainId - The chain ID to check + * @returns True if the chain is a supported non-EVM chain, false otherwise + */ +export const isNonEvmChainId = ( + chainId: GenericQuoteRequest['srcChainId'], +): boolean => { + return isSolanaChainId(chainId) || isBitcoinChainId(chainId); +}; + /** * Checks whether the transaction is a cross-chain transaction by comparing the source and destination chainIds * diff --git a/packages/bridge-controller/src/utils/fetch.ts b/packages/bridge-controller/src/utils/fetch.ts index 625c2998842..8447fb27a24 100644 --- a/packages/bridge-controller/src/utils/fetch.ts +++ b/packages/bridge-controller/src/utils/fetch.ts @@ -2,11 +2,16 @@ import { StructError } from '@metamask/superstruct'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; import { Duration } from '@metamask/utils'; +import { isBitcoinChainId } from './bridge'; import { formatAddressToCaipReference, formatChainIdToDec, } from './caip-formatters'; -import { validateQuoteResponse, validateSwapsTokenObject } from './validators'; +import { + validateQuoteResponse, + validateBitcoinQuoteResponse, + validateSwapsTokenObject, +} from './validators'; import type { QuoteResponse, FetchFunction, @@ -122,6 +127,11 @@ export async function fetchBridgeQuotes( const filteredQuotes = quotes.filter( (quoteResponse: unknown): quoteResponse is QuoteResponse => { try { + const isBitcoinQuote = isBitcoinChainId(request.srcChainId); + + if (isBitcoinQuote) { + return validateBitcoinQuoteResponse(quoteResponse); + } return validateQuoteResponse(quoteResponse); } catch (error) { if (error instanceof StructError) { diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index f2cce805a97..7a3a5b6dbf6 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -5,7 +5,7 @@ import { BigNumber } from 'bignumber.js'; import { isValidQuoteRequest, getQuoteIdentifier, - calcSolanaTotalNetworkFee, + calcNonEvmTotalNetworkFee, calcToAmount, calcSentAmount, calcRelayerFee, @@ -22,7 +22,7 @@ import type { GenericQuoteRequest, QuoteResponse, Quote, - SolanaFees, + NonEvmFees, L1GasFees, TxData, } from '../types'; @@ -256,15 +256,15 @@ describe('Quote Metadata Utils', () => { }); }); - describe('calcSolanaTotalNetworkFee', () => { - const mockBridgeQuote: QuoteResponse & SolanaFees = { - solanaFeesInLamports: '1000000000', + describe('calcNonEvmTotalNetworkFee', () => { + const mockBridgeQuote: QuoteResponse & NonEvmFees = { + nonEvmFeesInNative: '1', quote: {} as Quote, trade: {}, - } as QuoteResponse & SolanaFees; + } as QuoteResponse & NonEvmFees; it('should calculate Solana fees correctly with exchange rates', () => { - const result = calcSolanaTotalNetworkFee(mockBridgeQuote, { + const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, { exchangeRate: '2', usdExchangeRate: '1.5', }); @@ -274,8 +274,25 @@ describe('Quote Metadata Utils', () => { expect(result.usd).toBe('1.5'); }); + it('should calculate Bitcoin fees correctly with exchange rates', () => { + const btcQuote: QuoteResponse & NonEvmFees = { + nonEvmFeesInNative: '0.00005', // BTC fee in native units + quote: {} as Quote, + trade: {}, + } as QuoteResponse & NonEvmFees; + + const result = calcNonEvmTotalNetworkFee(btcQuote, { + exchangeRate: '60000', + usdExchangeRate: '60000', + }); + + expect(result.amount).toBe('0.00005'); + expect(result.valueInCurrency).toBe('3'); // 0.00005 * 60000 = 3 + expect(result.usd).toBe('3'); // 0.00005 * 60000 = 3 + }); + it('should handle missing exchange rates', () => { - const result = calcSolanaTotalNetworkFee(mockBridgeQuote, {}); + const result = calcNonEvmTotalNetworkFee(mockBridgeQuote, {}); expect(result.amount).toBe('1'); expect(result.valueInCurrency).toBeNull(); @@ -283,8 +300,8 @@ describe('Quote Metadata Utils', () => { }); it('should handle zero fees', () => { - const result = calcSolanaTotalNetworkFee( - { ...mockBridgeQuote, solanaFeesInLamports: '0' }, + const result = calcNonEvmTotalNetworkFee( + { ...mockBridgeQuote, nonEvmFeesInNative: '0' }, { exchangeRate: '2', usdExchangeRate: '1.5' }, ); diff --git a/packages/bridge-controller/src/utils/quote.ts b/packages/bridge-controller/src/utils/quote.ts index 2d4c4ed1c4f..a5284e45e8f 100644 --- a/packages/bridge-controller/src/utils/quote.ts +++ b/packages/bridge-controller/src/utils/quote.ts @@ -5,7 +5,7 @@ import { } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import { isNativeAddress, isSolanaChainId } from './bridge'; +import { isNativeAddress, isNonEvmChainId } from './bridge'; import type { BridgeAsset, ExchangeRate, @@ -14,7 +14,7 @@ import type { Quote, QuoteMetadata, QuoteResponse, - SolanaFees, + NonEvmFees, } from '../types'; export const isValidQuoteRequest = ( @@ -31,12 +31,18 @@ export const isValidQuoteRequest = ( if (requireAmount) { stringFields.push('srcTokenAmount'); } - // If bridging and one of the chains is solana, require the dest wallet address + // If bridging between different chain types or different non-EVM chains, require dest wallet address + // Cases that need destWalletAddress: + // 1. EVM -> non-EVM + // 2. non-EVM -> EVM + // 3. non-EVM -> different non-EVM (e.g., SOL -> BTC) + // Only same-chain swaps don't need destWalletAddress if ( partialRequest.destChainId && partialRequest.srcChainId && - isSolanaChainId(partialRequest.destChainId) === - !isSolanaChainId(partialRequest.srcChainId) + partialRequest.destChainId !== partialRequest.srcChainId && // Different chains + (isNonEvmChainId(partialRequest.destChainId) || + isNonEvmChainId(partialRequest.srcChainId)) // At least one is non-EVM ) { stringFields.push('destWalletAddress'); if (!partialRequest.destWalletAddress) { @@ -88,20 +94,20 @@ const calcTokenAmount = (value: string | BigNumber, decimals: number) => { return new BigNumber(value).div(divisor); }; -export const calcSolanaTotalNetworkFee = ( - bridgeQuote: QuoteResponse & SolanaFees, +export const calcNonEvmTotalNetworkFee = ( + bridgeQuote: QuoteResponse & NonEvmFees, { exchangeRate, usdExchangeRate }: ExchangeRate, ) => { - const { solanaFeesInLamports } = bridgeQuote; - const solanaFeeInNative = calcTokenAmount(solanaFeesInLamports ?? '0', 9); + const { nonEvmFeesInNative } = bridgeQuote; + // Fees are now stored directly in native units (SOL, BTC) without conversion + const feeInNative = new BigNumber(nonEvmFeesInNative ?? '0'); + return { - amount: solanaFeeInNative.toString(), + amount: feeInNative.toString(), valueInCurrency: exchangeRate - ? solanaFeeInNative.times(exchangeRate).toString() - : null, - usd: usdExchangeRate - ? solanaFeeInNative.times(usdExchangeRate).toString() + ? feeInNative.times(exchangeRate).toString() : null, + usd: usdExchangeRate ? feeInNative.times(usdExchangeRate).toString() : null, }; }; diff --git a/packages/bridge-controller/src/utils/snaps.test.ts b/packages/bridge-controller/src/utils/snaps.test.ts new file mode 100644 index 00000000000..3ae39c081eb --- /dev/null +++ b/packages/bridge-controller/src/utils/snaps.test.ts @@ -0,0 +1,78 @@ +import { SolScope } from '@metamask/keyring-api'; +import { v4 as uuid } from 'uuid'; + +import { + getMinimumBalanceForRentExemptionRequest, + computeFeeRequest, +} from './snaps'; + +jest.mock('uuid', () => ({ + v4: jest.fn(), +})); + +describe('Snaps Utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + (uuid as jest.Mock).mockReturnValue('test-uuid-1234'); + }); + + describe('getMinimumBalanceForRentExemptionRequest', () => { + it('should create a proper request for getting minimum balance for rent exemption', () => { + const snapId = 'test-snap-id'; + const result = getMinimumBalanceForRentExemptionRequest(snapId); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onProtocolRequest'); + expect(result.request.method).toBe(' '); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.params.scope).toBe(SolScope.Mainnet); + expect(result.request.params.request.id).toBe('test-uuid-1234'); + expect(result.request.params.request.jsonrpc).toBe('2.0'); + expect(result.request.params.request.method).toBe( + 'getMinimumBalanceForRentExemption', + ); + expect(result.request.params.request.params).toStrictEqual([ + 0, + { commitment: 'confirmed' }, + ]); + }); + }); + + describe('computeFeeRequest', () => { + it('should create a proper request for computing fees', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + + const result = computeFeeRequest(snapId, transaction, accountId, scope); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('computeFee'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params.scope).toBe(scope); + }); + + it('should handle different chain scopes', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const accountId = 'test-account-id'; + const btcScope = 'bip122:000000000019d6689c085ae165831e93' as const; + + const result = computeFeeRequest( + snapId, + transaction, + accountId, + btcScope, + ); + + expect(result.request.params.scope).toBe(btcScope); + }); + }); +}); diff --git a/packages/bridge-controller/src/utils/snaps.ts b/packages/bridge-controller/src/utils/snaps.ts index 7663e546ad5..fbd3bb0ad85 100644 --- a/packages/bridge-controller/src/utils/snaps.ts +++ b/packages/bridge-controller/src/utils/snaps.ts @@ -1,4 +1,5 @@ import { SolScope } from '@metamask/keyring-api'; +import type { CaipChainId } from '@metamask/utils'; import { v4 as uuid } from 'uuid'; export const getMinimumBalanceForRentExemptionRequest = (snapId: string) => { @@ -22,19 +23,34 @@ export const getMinimumBalanceForRentExemptionRequest = (snapId: string) => { }; }; -export const getFeeForTransactionRequest = ( +/** + * Creates a request to compute fees for a transaction using the new unified interface + * Returns fees in native token amount (e.g., Solana instead of Lamports) + * + * @param snapId - The snap ID to send the request to + * @param transaction - The base64 encoded transaction string + * @param accountId - The account ID + * @param scope - The CAIP-2 chain scope + * @returns The snap request object + */ +export const computeFeeRequest = ( snapId: string, transaction: string, + accountId: string, + scope: CaipChainId, ) => { return { snapId: snapId as never, origin: 'metamask', - handler: 'onRpcRequest' as never, + handler: 'onClientRequest' as never, request: { - method: 'getFeeForTransaction', + id: uuid(), + jsonrpc: '2.0', + method: 'computeFee', params: { transaction, - scope: SolScope.Mainnet, + accountId, + scope, }, }, }; diff --git a/packages/bridge-controller/src/utils/validators.ts b/packages/bridge-controller/src/utils/validators.ts index adb6706ca0b..01672b429a4 100644 --- a/packages/bridge-controller/src/utils/validators.ts +++ b/packages/bridge-controller/src/utils/validators.ts @@ -220,11 +220,23 @@ export const TxDataSchema = type({ effectiveGas: optional(number()), }); +export const BitcoinTradeDataSchema = type({ + unsignedPsbtBase64: string(), + inputsToSign: nullable(array(type({}))), +}); + export const QuoteResponseSchema = type({ quote: QuoteSchema, estimatedProcessingTimeInSeconds: number(), approval: optional(TxDataSchema), - trade: union([TxDataSchema, string()]), + trade: union([TxDataSchema, BitcoinTradeDataSchema, string()]), +}); + +export const BitcoinQuoteResponseSchema = type({ + quote: QuoteSchema, + estimatedProcessingTimeInSeconds: number(), + approval: optional(TxDataSchema), + trade: BitcoinTradeDataSchema, }); export const validateQuoteResponse = ( @@ -233,3 +245,10 @@ export const validateQuoteResponse = ( assert(data, QuoteResponseSchema); return true; }; + +export const validateBitcoinQuoteResponse = ( + data: unknown, +): data is Infer => { + assert(data, BitcoinQuoteResponseSchema); + return true; +}; diff --git a/packages/bridge-status-controller/CHANGELOG.md b/packages/bridge-status-controller/CHANGELOG.md index 87222e7cbef..cbf9fe39139 100644 --- a/packages/bridge-status-controller/CHANGELOG.md +++ b/packages/bridge-status-controller/CHANGELOG.md @@ -17,13 +17,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Add support for Bitcoin bridge transactions ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Handle Bitcoin PSBT (Partially Signed Bitcoin Transaction) format in trade data + - Support Bitcoin transaction submission through unified Snap interface + - Add Bitcoin-specific transaction handling in `#handleNonEvmTx` method + - Support extraction of `unsignedPsbtBase64` from trade data for Bitcoin transactions - Add new controller metadata properties to `BridgeStatusController` ([#6589](https://github.com/MetaMask/core/pull/6589)) ### Changed +- Update transaction submission to use new unified Snap interface for all non-EVM chains ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Replace `signAndSendTransactionWithoutConfirmation` with `ClientRequest:signAndSendTransaction` method + - Update response handling to support new `transactionId` format from unified interface + - Support multiple response formats: string, `{ transactionId }`, `{ result: { signature } }`, and `{ signature }` + - Maintain backward compatibility with legacy response formats +- Rename transaction handling functions for clarity ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Rename `handleSolanaTxResponse` to `handleNonEvmTxResponse` to reflect support for all non-EVM chains + - Rename `#handleSolanaTx` to `#handleNonEvmTx` in BridgeStatusController + - Export `handleSolanaTxResponse` as an alias for backward compatibility (deprecated) +- Update transaction detection logic to identify non-EVM transactions ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Check for Bitcoin PSBT format (`unsignedPsbtBase64` in trade object) alongside string trade data + - Use `isNonEvmChainId` for determining non-EVM transaction handling +- Update chain ID handling for non-EVM chains ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Add fallback chain ID (`0x0`) when CAIP format can't be converted to hex for source chains + - Add fallback chain ID (`0x1`) for non-EVM destination chains +- Update `getClientRequest` to create proper requests for all non-EVM chains ([#6454](https://github.com/MetaMask/core/pull/6454)) + - Use `formatChainIdToCaip` to get proper scope for each chain + - Extract transaction data from either string or PSBT object format +- Remove dependency on `@metamask/keyring-api` ([#6454](https://github.com/MetaMask/core/pull/6454)) - Bump `@metamask/controller-utils` from `^11.12.0` to `^11.14.0` ([#6620](https://github.com/MetaMask/core/pull/6620), [#6629](https://github.com/MetaMask/core/pull/6629)) - Bump `@metamask/base-controller` from `^8.3.0` to `^8.4.0` ([#6632](https://github.com/MetaMask/core/pull/6632)) +### Removed + +- Remove direct dependency on `@metamask/keyring-api` - no longer needed with unified Snap interface ([#6454](https://github.com/MetaMask/core/pull/6454)) + ## [43.0.0] ### Changed 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 33600cc7b71..e14f4a0b25e 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 @@ -3026,11 +3026,9 @@ Array [ "request": Object { "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": "signAndSendTransactionWithoutConfirmation", + "method": "signAndSendTransaction", "params": Object { - "account": Object { - "address": "0x123...", - }, + "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -3104,11 +3102,9 @@ Array [ "request": Object { "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": "signAndSendTransactionWithoutConfirmation", + "method": "signAndSendTransaction", "params": Object { - "account": Object { - "address": "0x123...", - }, + "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -3353,11 +3349,9 @@ Array [ "request": Object { "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": "signAndSendTransactionWithoutConfirmation", + "method": "signAndSendTransaction", "params": Object { - "account": Object { - "address": "0x123...", - }, + "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, @@ -3431,11 +3425,9 @@ Array [ "request": Object { "id": "test-uuid-1234", "jsonrpc": "2.0", - "method": "signAndSendTransactionWithoutConfirmation", + "method": "signAndSendTransaction", "params": Object { - "account": Object { - "address": "0x123...", - }, + "accountId": "solana-account-1", "scope": "solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp", "transaction": "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=", }, 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 86025dabc5e..242f972bf1e 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.test.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.test.ts @@ -1766,6 +1766,7 @@ describe('BridgeStatusController', () => { }; const mockSolanaAccount = { + id: 'solana-account-1', address: '0x123...', metadata: { snap: { @@ -1972,6 +1973,7 @@ describe('BridgeStatusController', () => { }; const mockSolanaAccount = { + id: 'solana-account-1', address: '0x123...', metadata: { snap: { diff --git a/packages/bridge-status-controller/src/bridge-status-controller.ts b/packages/bridge-status-controller/src/bridge-status-controller.ts index 03991fbe26f..cfdaeb34912 100644 --- a/packages/bridge-status-controller/src/bridge-status-controller.ts +++ b/packages/bridge-status-controller/src/bridge-status-controller.ts @@ -8,7 +8,7 @@ import type { } from '@metamask/bridge-controller'; import { formatChainIdToHex, - isSolanaChainId, + isNonEvmChainId, StatusTypes, UnifiedSwapBridgeEventName, formatChainIdToCaip, @@ -71,9 +71,9 @@ import { getUSDTAllowanceResetTx, handleLineaDelay, handleMobileHardwareWalletDelay, - handleSolanaTxResponse, + handleNonEvmTxResponse, + generateActionId, } from './utils/transaction'; -import { generateActionId } from './utils/transaction'; const metadata: StateMetadata = { // We want to persist the bridge status state so that we can show the proper data for the Activity list @@ -237,7 +237,7 @@ export class BridgeStatusController extends StaticIntervalPollingController & QuoteMetadata, + readonly #handleNonEvmTx = async ( + quoteResponse: QuoteResponse & + QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ) => { if (!selectedAccount.metadata?.snap?.id) { @@ -760,10 +762,13 @@ export class BridgeStatusController extends StaticIntervalPollingController } | { signature: string }; + )) as + | string + | { transactionId: string } + | { result: Record } + | { signature: string }; - // The extension client actually redirects before it can do anytyhing with this meta - const txMeta = handleSolanaTxResponse( + const txMeta = handleNonEvmTxResponse( requestResponse, quoteResponse, selectedAccount, @@ -1040,11 +1045,15 @@ export class BridgeStatusController extends StaticIntervalPollingController { try { - return await this.#handleSolanaTx( - quoteResponse as QuoteResponse & QuoteMetadata, + return await this.#handleNonEvmTx( + quoteResponse as QuoteResponse< + string | { unsignedPsbtBase64: string } + > & + QuoteMetadata, selectedAccount, ); } catch (error) { @@ -1148,10 +1160,10 @@ export class BridgeStatusController extends StaticIntervalPollingController ({ + v4: jest.fn(), +})); + +describe('Snaps Utils', () => { + beforeEach(() => { + jest.clearAllMocks(); + (uuid as jest.Mock).mockReturnValue('test-uuid-1234'); + }); + + describe('createClientTransactionRequest', () => { + it('should create a proper request without options', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + ); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('signAndSendTransaction'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.scope).toBe(scope); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should create a proper request with options', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + const options = { + skipPreflight: true, + maxRetries: 3, + }; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + options, + ); + + expect(result.snapId).toBe(snapId); + expect(result.origin).toBe('metamask'); + expect(result.handler).toBe('onClientRequest'); + expect(result.request.id).toBe('test-uuid-1234'); + expect(result.request.jsonrpc).toBe('2.0'); + expect(result.request.method).toBe('signAndSendTransaction'); + expect(result.request.params.transaction).toBe(transaction); + expect(result.request.params.scope).toBe(scope); + expect(result.request.params.accountId).toBe(accountId); + expect(result.request.params.options).toStrictEqual(options); + }); + + it('should handle different chain scopes', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const tronScope = 'tron:0x2b6653dc' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + tronScope, + accountId, + ); + + expect(result.request.params.scope).toBe(tronScope); + }); + + it('should not include options key when options is undefined', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + undefined, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should not include options key when options is null', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + null as unknown as Record, + ); + + expect(result.request.params).not.toHaveProperty('options'); + }); + + it('should include options key when options is empty object', () => { + const snapId = 'test-snap-id'; + const transaction = 'base64-encoded-transaction'; + const scope = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' as const; + const accountId = 'test-account-id'; + + const result = createClientTransactionRequest( + snapId, + transaction, + scope, + accountId, + {}, + ); + + expect(result.request.params).toHaveProperty('options'); + expect(result.request.params.options).toStrictEqual({}); + }); + }); +}); diff --git a/packages/bridge-status-controller/src/utils/snaps.ts b/packages/bridge-status-controller/src/utils/snaps.ts new file mode 100644 index 00000000000..748a1434c29 --- /dev/null +++ b/packages/bridge-status-controller/src/utils/snaps.ts @@ -0,0 +1,38 @@ +import type { CaipChainId } from '@metamask/utils'; +import { v4 as uuid } from 'uuid'; + +/** + * Creates a client request object for signing and sending a transaction + * Works for Solana, BTC, Tron, and other non-EVM networks + * + * @param snapId - The snap ID to send the request to + * @param transaction - The base64 encoded transaction string + * @param scope - The CAIP-2 chain scope + * @param accountId - The account ID + * @param options - Optional network-specific options + * @returns The snap request object + */ +export const createClientTransactionRequest = ( + snapId: string, + transaction: string, + scope: CaipChainId, + accountId: string, + options?: Record, +) => { + return { + snapId: snapId as never, + origin: 'metamask', + handler: 'onClientRequest' as never, + request: { + id: uuid(), + jsonrpc: '2.0', + method: 'signAndSendTransaction', + params: { + transaction, + scope, + accountId, + ...(options && { options }), + }, + }, + }; +}; diff --git a/packages/bridge-status-controller/src/utils/transaction.test.ts b/packages/bridge-status-controller/src/utils/transaction.test.ts index 3fe26ba417b..9cfa3ad81d8 100644 --- a/packages/bridge-status-controller/src/utils/transaction.test.ts +++ b/packages/bridge-status-controller/src/utils/transaction.test.ts @@ -7,7 +7,6 @@ import { type QuoteResponse, type TxData, } from '@metamask/bridge-controller'; -import { SolScope } from '@metamask/keyring-api'; import { TransactionStatus, TransactionType, @@ -16,7 +15,7 @@ import { import { getStatusRequestParams, getTxMetaFields, - handleSolanaTxResponse, + handleNonEvmTxResponse, handleLineaDelay, handleMobileHardwareWalletDelay, getClientRequest, @@ -251,7 +250,6 @@ describe('Bridge Status Controller Transaction Utils', () => { destinationTokenAddress: '0x0000000000000000000000000000000000000000', approvalTxId: undefined, swapTokenValue: '1.0', - chainId: '0x1', }); }); @@ -336,6 +334,87 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.approvalTxId).toBe(approvalTxId); }); + + it('should use fallback chain ID for non-EVM destination chains', () => { + const mockQuoteResponse = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.ETH, + destChainId: 'bip122:000000000019d6689c085ae165831e93', // Bitcoin CAIP format + srcTokenAmount: '1000000000000000000', + destTokenAmount: '100000', // satoshis + srcAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + destAsset: { + address: 'bc1qxxx', + decimals: 8, + symbol: 'BTC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000000000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: { + value: '0x0', + gasLimit: '21000', + }, + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '3000', + usd: '3000', + }, + toTokenAmount: { + amount: '0.001', + valueInCurrency: '3000', + usd: '3000', + }, + minToTokenAmount: { + amount: '0.00095', + valueInCurrency: '2850', + usd: '2850', + }, + swapRate: '0.001', + totalNetworkFee: { + amount: '0.01', + valueInCurrency: '30', + usd: '30', + }, + totalMaxNetworkFee: { + amount: '0.015', + valueInCurrency: '45', + usd: '45', + }, + gasFee: { + amount: '0.01', + valueInCurrency: '30', + usd: '30', + }, + adjustedReturn: { + valueInCurrency: '2970', + usd: '2970', + }, + cost: { + valueInCurrency: '30', + usd: '30', + }, + }; + + const result = getTxMetaFields(mockQuoteResponse as never); + + // Should use fallback mainnet chain ID when CAIP format can't be converted to hex + expect(result.destinationChainId).toBe('0x1'); + expect(result.destinationTokenSymbol).toBe('BTC'); + expect(result.destinationTokenDecimals).toBe(8); + }); }); const snapId = 'snapId123'; @@ -349,7 +428,7 @@ describe('Bridge Status Controller Transaction Utils', () => { address: selectedAccountAddress, } as never; - describe('handleSolanaTxResponse', () => { + describe('handleNonEvmTxResponse', () => { it('should handle string response format', () => { const mockQuoteResponse: QuoteResponse & QuoteMetadata = { quote: { @@ -424,7 +503,7 @@ describe('Bridge Status Controller Transaction Utils', () => { const signature = 'solanaSignature123'; - const result = handleSolanaTxResponse(signature, mockQuoteResponse, { + const result = handleNonEvmTxResponse(signature, mockQuoteResponse, { metadata: { snap: { id: undefined }, }, @@ -534,7 +613,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -619,7 +698,7 @@ describe('Bridge Status Controller Transaction Utils', () => { signature: 'solanaSignature123', }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -707,7 +786,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -794,7 +873,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -881,7 +960,7 @@ describe('Bridge Status Controller Transaction Utils', () => { }, }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -890,6 +969,101 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.hash).toBe('solanaTxHash123'); }); + it('should handle new unified interface response with transactionId', () => { + const mockQuoteResponse: QuoteResponse & QuoteMetadata = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: ChainId.SOLANA, + destChainId: ChainId.POLYGON, + srcTokenAmount: '1000000000', + destTokenAmount: '2000000000000000000', + minDestTokenAmount: '1900000000000000000', + srcAsset: { + address: 'solanaNativeAddress', + decimals: 9, + symbol: 'SOL', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'MATIC', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '100000000', + }, + }, + }, + estimatedProcessingTimeInSeconds: 300, + trade: 'ABCD', + solanaFeesInLamports: '5000', + // QuoteMetadata fields + sentAmount: { + amount: '1.0', + valueInCurrency: '100', + usd: '100', + }, + toTokenAmount: { + amount: '2.0', + valueInCurrency: '3600', + usd: '3600', + }, + minToTokenAmount: { + amount: '1.9', + valueInCurrency: '3420', + usd: '3420', + }, + swapRate: '2.0', + totalNetworkFee: { + amount: '0.1', + valueInCurrency: '10', + usd: '10', + }, + totalMaxNetworkFee: { + amount: '0.15', + valueInCurrency: '15', + usd: '15', + }, + gasFee: { + amount: '0.05', + valueInCurrency: '5', + usd: '5', + }, + adjustedReturn: { + valueInCurrency: '3585', + usd: '3585', + }, + cost: { + valueInCurrency: '0.1', + usd: '0.1', + }, + } as never; + + const snapResponse = { transactionId: 'new-unified-tx-id-123' }; + + const result = handleNonEvmTxResponse( + snapResponse, + mockQuoteResponse, + mockSolanaAccount, + ); + + expect(result.hash).toBe('new-unified-tx-id-123'); + expect(result.chainId).toBe(formatChainIdToHex(ChainId.SOLANA)); + expect(result.type).toBe(TransactionType.bridge); + expect(result.status).toBe(TransactionStatus.submitted); + expect(result.destinationTokenAmount).toBe('2000000000000000000'); + expect(result.destinationTokenSymbol).toBe('MATIC'); + expect(result.destinationTokenDecimals).toBe(18); + expect(result.destinationTokenAddress).toBe( + '0x0000000000000000000000000000000000000000', + ); + expect(result.swapTokenValue).toBe('1.0'); + expect(result.isSolana).toBe(true); + expect(result.isBridgeTx).toBe(true); + }); + it('should handle empty or invalid response', () => { const mockQuoteResponse: QuoteResponse & QuoteMetadata = { quote: { @@ -964,7 +1138,7 @@ describe('Bridge Status Controller Transaction Utils', () => { const snapResponse = { result: {} } as { result: Record }; - const result = handleSolanaTxResponse( + const result = handleNonEvmTxResponse( snapResponse, mockQuoteResponse, mockSolanaAccount, @@ -972,6 +1146,96 @@ describe('Bridge Status Controller Transaction Utils', () => { expect(result.hash).toBeUndefined(); }); + + it('should handle Bitcoin transaction with PSBT and non-EVM chain ID', () => { + const mockBitcoinQuote = { + quote: { + bridgeId: 'bridge1', + bridges: ['bridge1'], + srcChainId: 'bip122:000000000019d6689c085ae165831e93', + destChainId: ChainId.ETH, + srcTokenAmount: '100000', + destTokenAmount: '1000000000000000000', + minDestTokenAmount: '950000000000000000', + srcAsset: { + address: 'bc1qxxx', + decimals: 8, + symbol: 'BTC', + }, + destAsset: { + address: '0x0000000000000000000000000000000000000000', + decimals: 18, + symbol: 'ETH', + }, + steps: ['step1'], + feeData: { + [FeeType.METABRIDGE]: { + amount: '500', + }, + }, + }, + estimatedProcessingTimeInSeconds: 600, + trade: { + unsignedPsbtBase64: 'cHNidP8BAH0CAAAAAe...', + }, + // QuoteMetadata fields + sentAmount: { + amount: '0.001', + valueInCurrency: '60', + usd: '60', + }, + toTokenAmount: { + amount: '1.0', + valueInCurrency: '3000', + usd: '3000', + }, + minToTokenAmount: { + amount: '0.95', + valueInCurrency: '2850', + usd: '2850', + }, + swapRate: '1000', + totalNetworkFee: { + amount: '0.00005', + valueInCurrency: '3', + usd: '3', + }, + totalMaxNetworkFee: { + amount: '0.00007', + valueInCurrency: '4.2', + usd: '4.2', + }, + gasFee: { + amount: '0.00005', + valueInCurrency: '3', + usd: '3', + }, + adjustedReturn: { + valueInCurrency: '2997', + usd: '2997', + }, + cost: { + valueInCurrency: '3', + usd: '3', + }, + }; + + const snapResponse = { transactionId: 'btc_tx_123' }; + + const result = handleNonEvmTxResponse( + snapResponse, + mockBitcoinQuote as never, + mockSolanaAccount, + ); + + // Should use fallback chain ID (0x1 - Ethereum mainnet) when Bitcoin CAIP format can't be converted + expect(result.chainId).toBe('0x1'); + expect(result.hash).toBe('btc_tx_123'); + expect(result.type).toBe(TransactionType.bridge); + expect(result.sourceTokenSymbol).toBe('BTC'); + expect(result.destinationTokenSymbol).toBe('ETH'); + expect(result.isBridgeTx).toBe(true); + }); }); describe('handleLineaDelay', () => { @@ -1190,11 +1454,11 @@ describe('Bridge Status Controller Transaction Utils', () => { request: { id: expect.any(String), jsonrpc: '2.0', - method: 'signAndSendTransactionWithoutConfirmation', + method: 'signAndSendTransaction', params: { - account: { address: '0x123456' }, transaction: 'ABCD', - scope: SolScope.Mainnet, + scope: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', + accountId: 'test-account-id', }, }, }); diff --git a/packages/bridge-status-controller/src/utils/transaction.ts b/packages/bridge-status-controller/src/utils/transaction.ts index 70046f5b689..49a420aa417 100644 --- a/packages/bridge-status-controller/src/utils/transaction.ts +++ b/packages/bridge-status-controller/src/utils/transaction.ts @@ -2,6 +2,7 @@ import type { AccountsControllerState } from '@metamask/accounts-controller'; import type { TxData } from '@metamask/bridge-controller'; import { ChainId, + formatChainIdToCaip, formatChainIdToHex, getEthUsdtResetData, isCrossChain, @@ -10,7 +11,6 @@ import { type QuoteResponse, } from '@metamask/bridge-controller'; import { toHex } from '@metamask/controller-utils'; -import { SolScope } from '@metamask/keyring-api'; import type { BatchTransactionParams, TransactionController, @@ -25,6 +25,7 @@ import { BigNumber } from 'bignumber.js'; import { v4 as uuid } from 'uuid'; import { calculateGasFees } from './gas'; +import { createClientTransactionRequest } from './snaps'; import type { TransactionBatchSingleRequest } from '../../../transaction-controller/src/types'; import { LINEA_DELAY_MS } from '../constants'; import type { @@ -78,10 +79,19 @@ export const getTxMetaFields = ( approvalTxId?: string, ): Omit< TransactionMeta, - 'networkClientId' | 'status' | 'time' | 'txParams' | 'id' + 'networkClientId' | 'status' | 'time' | 'txParams' | 'id' | 'chainId' > => { + // Handle destination chain ID - should always be convertible for EVM destinations + let destinationChainId; + try { + destinationChainId = formatChainIdToHex(quoteResponse.quote.destChainId); + } catch { + // Fallback for non-EVM destination (shouldn't happen for BTC->EVM) + destinationChainId = '0x1' as `0x${string}`; // Default to mainnet + } + return { - destinationChainId: formatChainIdToHex(quoteResponse.quote.destChainId), + destinationChainId, sourceTokenAmount: quoteResponse.quote.srcTokenAmount, sourceTokenSymbol: quoteResponse.quote.srcAsset.symbol, sourceTokenDecimals: quoteResponse.quote.srcAsset.decimals, @@ -92,19 +102,34 @@ export const getTxMetaFields = ( destinationTokenDecimals: quoteResponse.quote.destAsset.decimals, destinationTokenAddress: quoteResponse.quote.destAsset.address, - chainId: formatChainIdToHex(quoteResponse.quote.srcChainId), + // chainId is now excluded from this function and handled by the caller approvalTxId, // this is the decimal (non atomic) amount (not USD value) of source token to swap swapTokenValue: quoteResponse.sentAmount.amount, }; }; -export const handleSolanaTxResponse = ( +/** + * Handles the response from non-EVM transaction submission + * Works with the new unified ClientRequest:signAndSendTransaction interface + * Supports Solana, Bitcoin, and other non-EVM chains + * + * @param snapResponse - The response from the snap after transaction submission + * @param quoteResponse - The quote response containing trade details and metadata + * @param selectedAccount - The selected account information + * @returns The transaction metadata including non-EVM specific fields + */ +export const handleNonEvmTxResponse = ( snapResponse: | string + | { transactionId: string } // New unified interface response | { result: Record } | { signature: string }, - quoteResponse: Omit, 'approval'> & QuoteMetadata, + quoteResponse: Omit< + QuoteResponse, + 'approval' + > & + QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ): TransactionMeta & SolanaTransactionMeta => { const selectedAccountAddress = selectedAccount.address; @@ -114,9 +139,10 @@ export const handleSolanaTxResponse = ( if (typeof snapResponse === 'string') { hash = snapResponse; } else if (snapResponse && typeof snapResponse === 'object') { - // If it's an object with result property, try to get the signature - if ( - typeof snapResponse === 'object' && + // Check for new unified interface response format first + if ('transactionId' in snapResponse && snapResponse.transactionId) { + hash = snapResponse.transactionId; + } else if ( 'result' in snapResponse && snapResponse.result && typeof snapResponse.result === 'object' @@ -127,9 +153,7 @@ export const handleSolanaTxResponse = ( snapResponse.result.txid || snapResponse.result.hash || snapResponse.result.txHash; - } - if ( - typeof snapResponse === 'object' && + } else if ( 'signature' in snapResponse && snapResponse.signature && typeof snapResponse.signature === 'string' @@ -138,12 +162,26 @@ export const handleSolanaTxResponse = ( } } - const hexChainId = formatChainIdToHex(quoteResponse.quote.srcChainId); const isBridgeTx = isCrossChain( quoteResponse.quote.srcChainId, quoteResponse.quote.destChainId, ); + let hexChainId; + try { + hexChainId = formatChainIdToHex(quoteResponse.quote.srcChainId); + } catch { + // TODO: Fix chain ID activity list handling for Bitcoin + // Fallback to Ethereum mainnet for now + hexChainId = '0x1' as `0x${string}`; + } + + // Extract the transaction data for storage + const tradeData = + typeof quoteResponse.trade === 'string' + ? quoteResponse.trade + : quoteResponse.trade.unsignedPsbtBase64; + // Create a transaction meta object with bridge-specific fields return { ...getTxMetaFields(quoteResponse), @@ -151,13 +189,13 @@ export const handleSolanaTxResponse = ( id: hash ?? uuid(), chainId: hexChainId, networkClientId: snapId ?? hexChainId, - txParams: { from: selectedAccountAddress, data: quoteResponse.trade }, + txParams: { from: selectedAccountAddress, data: tradeData }, type: isBridgeTx ? TransactionType.bridge : TransactionType.swap, status: TransactionStatus.submitted, hash, // Add the transaction signature as hash origin: snapId, - // Add an explicit bridge flag to mark this as a Solana transaction - isSolana: true, // TODO deprecate this and use chainId + // Add an explicit flag to mark this as a non-EVM transaction + isSolana: true, // TODO deprecate this and use chainId to detect non-EVM chains isBridgeTx, }; }; @@ -195,27 +233,37 @@ export const handleMobileHardwareWalletDelay = async ( } }; +/** + * Creates a request to sign and send a transaction for non-EVM chains + * Uses the new unified ClientRequest:signAndSendTransaction interface + * + * @param quoteResponse - The quote response containing trade details and metadata + * @param selectedAccount - The selected account information + * @returns The snap request object for signing and sending transaction + */ export const getClientRequest = ( - quoteResponse: Omit, 'approval'> & QuoteMetadata, + quoteResponse: Omit< + QuoteResponse, + 'approval' + > & + QuoteMetadata, selectedAccount: AccountsControllerState['internalAccounts']['accounts'][string], ) => { - const clientReqId = uuid(); + const scope = formatChainIdToCaip(quoteResponse.quote.srcChainId); - return { - origin: 'metamask', - snapId: selectedAccount.metadata.snap?.id as never, - handler: 'onClientRequest' as never, - request: { - id: clientReqId, - jsonrpc: '2.0', - method: 'signAndSendTransactionWithoutConfirmation', - params: { - account: { address: selectedAccount.address }, - transaction: quoteResponse.trade, - scope: SolScope.Mainnet, - }, - }, - }; + // Extract the transaction data - Bitcoin uses unsignedPsbtBase64, others use string + const transactionData = + typeof quoteResponse.trade === 'string' + ? quoteResponse.trade + : quoteResponse.trade.unsignedPsbtBase64; + + // Use the new unified interface + return createClientTransactionRequest( + selectedAccount.metadata.snap?.id as string, + transactionData, + scope, + selectedAccount.id, + ); }; export const toBatchTxParams = (