From 1a01381b24ff05e9642b315603fa222195b9f4da Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Tue, 28 Apr 2026 16:55:19 +0200 Subject: [PATCH 1/6] feat: add support for Stellar Network in bridge-controller --- packages/bridge-controller/CHANGELOG.md | 1 + .../src/bridge-controller.test.ts | 126 ++++++++++++++++++ .../bridge-controller/src/constants/bridge.ts | 4 +- .../bridge-controller/src/constants/tokens.ts | 13 +- packages/bridge-controller/src/index.ts | 1 + packages/bridge-controller/src/types.ts | 1 + .../src/utils/bridge.test.ts | 35 ++++- .../bridge-controller/src/utils/bridge.ts | 14 +- .../src/utils/caip-formatters.test.ts | 41 +++++- .../src/utils/caip-formatters.ts | 18 ++- .../src/utils/feature-flags.test.ts | 16 +++ .../bridge-controller/src/utils/quote.test.ts | 50 ++++++- 12 files changed, 306 insertions(+), 14 deletions(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index a38534b0e0..b29c03c1b9 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `quickBuy` and `dappSwap` FeatureIds for external swap quote consumers ([#8598](https://github.com/MetaMask/core/pull/8598)) - **BREAKING:** Add `market_closed` and `quote_expired` QuoteWarning ([#8598](https://github.com/MetaMask/core/pull/8598)) +- Add Stellar network support for bridge quotes and non-EVM fee calculation (TODO: THIS PR) ### Changed diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index 17206df11d..42c29cc4b9 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -6,6 +6,8 @@ import { EthScope, SolAccountType, SolScope, + XlmAccountType, + XlmScope, } from '@metamask/keyring-api'; import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; import type { @@ -2791,6 +2793,130 @@ describe('BridgeController', function () { ); }); + it('should append Stellar fees for Stellar quotes', async () => { + await withController(async ({ controller: bridgeController }) => { + const stellarQuoteResponse = mockBridgeQuotesSolErc20.map((quote) => { + const stellarNativeAsset = getNativeAssetForChainId(ChainId.STELLAR); + return { + ...quote, + quote: { + ...quote.quote, + srcChainId: ChainId.STELLAR, + destChainId: ChainId.STELLAR, + srcAsset: stellarNativeAsset, + destAsset: { + ...stellarNativeAsset, + address: + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + assetId: + 'stellar:pubnet/sep41:CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + symbol: 'USDC', + name: 'USDC', + }, + feeData: { + ...quote.quote.feeData, + metabridge: { + ...quote.quote.feeData.metabridge, + asset: stellarNativeAsset, + }, + }, + steps: quote.quote.steps.map((step) => ({ + ...step, + srcChainId: ChainId.STELLAR, + destChainId: ChainId.STELLAR, + srcAsset: stellarNativeAsset, + destAsset: stellarNativeAsset, + })), + }, + }; + }) as unknown as QuoteResponse[]; + + messengerCallMock.mockImplementation( + ( + ...args: Parameters + ): ReturnType => { + const [actionType, params] = args; + + if (actionType === 'AuthenticationController:getBearerToken') { + return 'AUTH_TOKEN'; + } + + if (actionType === 'RemoteFeatureFlagController:getState') { + return { + remoteFeatureFlags: { + bridgeConfig, + }, + } as never; + } + + if (actionType === 'AccountsController:getAccountByAddress') { + return { + type: XlmAccountType.Account, + id: 'xlm-account-1', + scopes: [XlmScope.Pubnet], + methods: [], + address: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + metadata: { + name: 'Stellar Account 1', + importTime: 1717334400, + keyring: { + type: 'Snap Keyring', + }, + snap: { + id: 'npm:@metamask/stellar-snap', + name: 'Stellar Snap', + }, + }, + } as never; + } + + if (actionType === 'SnapController:handleRequest') { + expect( + (params as { request?: { params?: { scope?: string } } }).request + ?.params?.scope, + ).toBe(XlmScope.Pubnet); + return Promise.resolve([ + { + type: 'base', + asset: { + unit: 'XLM', + type: 'stellar:pubnet/slip44:148', + amount: '0.00001', + fungible: true, + }, + }, + ]) as never; + } + + return {} as never; + }, + ); + + jest.spyOn(fetchUtils, 'fetchBridgeQuotes').mockResolvedValue({ + quotes: stellarQuoteResponse, + validationFailures: [], + }); + + const quotes = await bridgeController.fetchQuotes({ + srcChainId: ChainId.STELLAR, + destChainId: ChainId.STELLAR, + srcTokenAddress: '0x0000000000000000000000000000000000000000', + destTokenAddress: + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75', + srcTokenAmount: '300000000', + walletAddress: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + gasIncluded: false, + gasIncluded7702: false, + }); + + expect(quotes).toHaveLength(2); + expect(quotes[0].nonEvmFeesInNative).toBe('0.00001'); + expect(quotes[1].nonEvmFeesInNative).toBe('0.00001'); + }); + }); + describe('trackUnifiedSwapBridgeEvent client-side calls', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/bridge-controller/src/constants/bridge.ts b/packages/bridge-controller/src/constants/bridge.ts index 14be143ec2..3d2c8b974c 100644 --- a/packages/bridge-controller/src/constants/bridge.ts +++ b/packages/bridge-controller/src/constants/bridge.ts @@ -1,5 +1,5 @@ import { AddressZero } from '@ethersproject/constants'; -import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; import type { Hex } from '@metamask/utils'; import type { @@ -24,6 +24,7 @@ export const ALLOWED_BRIDGE_CHAIN_IDS = [ CHAIN_IDS.MEGAETH, SolScope.Mainnet, BtcScope.Mainnet, + XlmScope.Pubnet, TrxScope.Mainnet, ] as const; @@ -55,6 +56,7 @@ export const DEFAULT_CHAIN_RANKING = [ { chainId: 'eip155:56', name: 'BNB' }, { chainId: 'bip122:000000000019d6689c085ae165831e93', name: 'BTC' }, { chainId: 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp', name: 'Solana' }, + { chainId: 'stellar:pubnet', name: 'Stellar' }, { chainId: 'tron:728126428', name: 'Tron' }, { chainId: 'eip155:8453', name: 'Base' }, { chainId: 'eip155:42161', name: 'Arbitrum' }, diff --git a/packages/bridge-controller/src/constants/tokens.ts b/packages/bridge-controller/src/constants/tokens.ts index 1c0ec09894..0609865d87 100644 --- a/packages/bridge-controller/src/constants/tokens.ts +++ b/packages/bridge-controller/src/constants/tokens.ts @@ -1,4 +1,4 @@ -import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; import type { AllowedBridgeChainIds } from './bridge'; import { CHAIN_IDS } from './chains'; @@ -55,6 +55,7 @@ const CURRENCY_SYMBOLS = { SOL: 'SOL', SEI: 'SEI', BTC: 'BTC', + XLM: 'XLM', TRX: 'TRX', MON: 'MON', HYPE: 'HYPE', @@ -153,6 +154,14 @@ const BTC_SWAPS_TOKEN_OBJECT = { iconUrl: '', } as const; +const XLM_SWAPS_TOKEN_OBJECT = { + symbol: CURRENCY_SYMBOLS.XLM, + name: 'Stellar Lumens', + address: DEFAULT_TOKEN_ADDRESS, + decimals: 7, + iconUrl: '', +} as const; + const SEI_SWAPS_TOKEN_OBJECT = { symbol: CURRENCY_SYMBOLS.SEI, name: 'Sei', @@ -209,6 +218,7 @@ export const SWAPS_CHAINID_DEFAULT_TOKEN_MAP = { [SolScope.Mainnet]: SOLANA_SWAPS_TOKEN_OBJECT, [SolScope.Devnet]: SOLANA_SWAPS_TOKEN_OBJECT, [BtcScope.Mainnet]: BTC_SWAPS_TOKEN_OBJECT, + [XlmScope.Pubnet]: XLM_SWAPS_TOKEN_OBJECT, [TrxScope.Mainnet]: TRX_SWAPS_TOKEN_OBJECT, } as const; @@ -227,6 +237,7 @@ export const SYMBOL_TO_SLIP44_MAP: Record< > = { SOL: 'slip44:501', BTC: 'slip44:0', + XLM: 'slip44:148', ETH: 'slip44:60', POL: 'slip44:966', BNB: 'slip44:714', diff --git a/packages/bridge-controller/src/index.ts b/packages/bridge-controller/src/index.ts index eb42636685..d7754cb3b7 100644 --- a/packages/bridge-controller/src/index.ts +++ b/packages/bridge-controller/src/index.ts @@ -139,6 +139,7 @@ export { isNativeAddress, isSolanaChainId, isBitcoinChainId, + isStellarChainId, isTronChainId, isNonEvmChainId, getNativeAssetForChainId, diff --git a/packages/bridge-controller/src/types.ts b/packages/bridge-controller/src/types.ts index 20f800bc0a..bdac5cbc6e 100644 --- a/packages/bridge-controller/src/types.ts +++ b/packages/bridge-controller/src/types.ts @@ -317,6 +317,7 @@ export enum ChainId { LINEA = 59144, SOLANA = 1151111081099710, BTC = 20000000000001, + STELLAR = 20000000000002, TRON = 728126428, SEI = 1329, MONAD = 143, diff --git a/packages/bridge-controller/src/utils/bridge.test.ts b/packages/bridge-controller/src/utils/bridge.test.ts index 97680af1e2..a300dcf4c6 100644 --- a/packages/bridge-controller/src/utils/bridge.test.ts +++ b/packages/bridge-controller/src/utils/bridge.test.ts @@ -1,4 +1,4 @@ -import { BtcScope, SolScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, XlmScope } from '@metamask/keyring-api'; import type { Hex } from '@metamask/utils'; import { @@ -15,6 +15,7 @@ import { isEthUsdt, isNonEvmChainId, isSolanaChainId, + isStellarChainId, isSwapsDefaultTokenAddress, isSwapsDefaultTokenSymbol, sumHexes, @@ -185,6 +186,23 @@ describe('Bridge utils', () => { }); }); + describe('isStellarChainId', () => { + it('returns true for ChainId.STELLAR', () => { + expect(isStellarChainId(ChainId.STELLAR)).toBe(true); + expect(isStellarChainId('20000000000002')).toBe(true); + }); + + it('returns true for XlmScope.Pubnet', () => { + expect(isStellarChainId(XlmScope.Pubnet)).toBe(true); + }); + + it('returns false for other chainIds', () => { + expect(isStellarChainId(1)).toBe(false); + expect(isStellarChainId('0x0')).toBe(false); + expect(isStellarChainId(XlmScope.Testnet)).toBe(false); + }); + }); + describe('isNonEvmChainId', () => { it('returns true for Solana chainIds', () => { expect(isNonEvmChainId(ChainId.SOLANA)).toBe(true); @@ -198,6 +216,12 @@ describe('Bridge utils', () => { expect(isNonEvmChainId('20000000000001')).toBe(true); }); + it('returns true for Stellar chainIds', () => { + expect(isNonEvmChainId(ChainId.STELLAR)).toBe(true); + expect(isNonEvmChainId(XlmScope.Pubnet)).toBe(true); + expect(isNonEvmChainId('20000000000002')).toBe(true); + }); + it('returns false for EVM chainIds', () => { expect(isNonEvmChainId('0x1')).toBe(false); expect(isNonEvmChainId(1)).toBe(false); @@ -268,6 +292,15 @@ describe('Bridge utils', () => { }); }); + it('should return native asset for Stellar chainId', () => { + const result = getNativeAssetForChainId(XlmScope.Pubnet); + expect(result).toStrictEqual({ + ...SWAPS_CHAINID_DEFAULT_TOKEN_MAP[XlmScope.Pubnet], + chainId: 20000000000002, + assetId: 'stellar:pubnet/slip44:148', + }); + }); + it('should throw error for unsupported chainId', () => { expect(() => getNativeAssetForChainId('999999')).toThrow( 'No XChain Swaps native asset found for chainId: 999999', diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index 204b164d51..a85f84e018 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -1,6 +1,6 @@ import { AddressZero } from '@ethersproject/constants'; import { Contract } from '@ethersproject/contracts'; -import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; import { abiERC20 } from '@metamask/metamask-eth-abis'; import { isCaipChainId, isStrictHexString } from '@metamask/utils'; import type { CaipAssetType, CaipChainId, Hex } from '@metamask/utils'; @@ -230,9 +230,18 @@ export const isTronChainId = (chainId: Hex | number | CaipChainId | string) => { return chainId.toString() === ChainId.TRON.toString(); }; +export const isStellarChainId = ( + chainId: Hex | number | CaipChainId | string, +) => { + if (isCaipChainId(chainId)) { + return chainId === XlmScope.Pubnet.toString(); + } + return chainId.toString() === ChainId.STELLAR.toString(); +}; + /** * Checks if a chain ID represents a non-EVM blockchain supported by swaps - * Currently supports Solana, Bitcoin and Tron + * Currently supports Solana, Bitcoin, Stellar and Tron * * @param chainId - The chain ID to check * @returns True if the chain is a supported non-EVM chain, false otherwise @@ -243,6 +252,7 @@ export const isNonEvmChainId = ( return ( isSolanaChainId(chainId) || isBitcoinChainId(chainId) || + isStellarChainId(chainId) || isTronChainId(chainId) ); }; diff --git a/packages/bridge-controller/src/utils/caip-formatters.test.ts b/packages/bridge-controller/src/utils/caip-formatters.test.ts index 6b39b96434..eff34dd507 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.test.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.test.ts @@ -1,5 +1,5 @@ import { AddressZero } from '@ethersproject/constants'; -import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; import { CHAIN_IDS } from '../constants/chains'; import { ChainId } from '../types'; @@ -41,6 +41,11 @@ describe('CAIP Formatters', () => { expect(formatChainIdToCaip(TrxScope.Mainnet)).toBe(TrxScope.Mainnet); }); + it('should convert Stellar chainId to XlmScope.Pubnet', () => { + expect(formatChainIdToCaip(ChainId.STELLAR)).toBe(XlmScope.Pubnet); + expect(formatChainIdToCaip(XlmScope.Pubnet)).toBe(XlmScope.Pubnet); + }); + it('should convert number to CAIP format', () => { expect(formatChainIdToCaip(1)).toBe('eip155:1'); }); @@ -68,6 +73,10 @@ describe('CAIP Formatters', () => { expect(formatChainIdToDec(TrxScope.Mainnet)).toBe(ChainId.TRON); }); + it('should handle Stellar pubnet', () => { + expect(formatChainIdToDec(XlmScope.Pubnet)).toBe(ChainId.STELLAR); + }); + it('should parse CAIP chainId to decimal', () => { expect(formatChainIdToDec('eip155:1')).toBe(1); }); @@ -111,6 +120,12 @@ describe('CAIP Formatters', () => { `Invalid cross-chain swaps chainId: ${SolScope.Mainnet}`, ); }); + + it('should throw error for Stellar chainId (non-EVM)', () => { + expect(() => formatChainIdToHex(XlmScope.Pubnet)).toThrow( + `Invalid cross-chain swaps chainId: ${XlmScope.Pubnet}`, + ); + }); }); describe('formatAddressToCaipReference', () => { @@ -133,6 +148,9 @@ describe('CAIP Formatters', () => { expect( formatAddressToCaipReference(`${BtcScope.Mainnet}/slip44:0`), ).toStrictEqual(AddressZero); + expect( + formatAddressToCaipReference(`${XlmScope.Pubnet}/slip44:148`), + ).toStrictEqual(AddressZero); }); it('should extract address from CAIP format', () => { @@ -193,6 +211,11 @@ describe('CAIP Formatters', () => { expect(result).toBe('bip122:000000000019d6689c085ae165831e93/slip44:0'); }); + it('should return native asset for chainId when address is Stellar native asset', () => { + const result = formatAddressToAssetId('148', XlmScope.Pubnet); + expect(result).toBe('stellar:pubnet/slip44:148'); + }); + it('should return native asset for chainId when address is BSC native asset', () => { const result = formatAddressToAssetId('714', '0x38'); expect(result).toBe('eip155:56/slip44:714'); @@ -277,5 +300,21 @@ describe('CAIP Formatters', () => { 'tron:728126428/trc20:TJ1234567890123456789012345678901234567890', ); }); + + it('should create Stellar classic asset type for CODE-ISSUER tokens', () => { + const tokenAddress = + 'VELO-GDM4RQUQQUVSKQA7S6EM7XBZP3FCGH4Q7CL6TABQ7B2BEJ5ERARM2M5M'; + expect(formatAddressToAssetId(tokenAddress, ChainId.STELLAR)).toBe( + `${XlmScope.Pubnet}/asset:${tokenAddress}`, + ); + }); + + it('should create Stellar SEP-41 asset type for Soroban contract IDs', () => { + const tokenAddress = + 'CCW67TSZV3SSS2HXMBQ5JFGCKJNXKZM7UQUWUZPUTHXSTZLEO7SJMI75'; + expect(formatAddressToAssetId(tokenAddress, ChainId.STELLAR)).toBe( + `${XlmScope.Pubnet}/sep41:${tokenAddress}`, + ); + }); }); }); diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index 450be976b0..2f5b6b12e4 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -5,7 +5,7 @@ import { convertHexToDecimal, toChecksumHexAddress, } from '@metamask/controller-utils'; -import { BtcScope, SolScope, TrxScope } from '@metamask/keyring-api'; +import { BtcScope, SolScope, TrxScope, XlmScope } from '@metamask/keyring-api'; import { toEvmCaipChainId } from '@metamask/multichain-network-controller'; import { isCaipChainId, @@ -25,6 +25,7 @@ import { isBitcoinChainId, isNativeAddress, isSolanaChainId, + isStellarChainId, isTronChainId, } from './bridge'; @@ -49,6 +50,9 @@ export const formatChainIdToCaip = ( if (isBitcoinChainId(chainId)) { return BtcScope.Mainnet; } + if (isStellarChainId(chainId)) { + return XlmScope.Pubnet; + } if (isTronChainId(chainId)) { return TrxScope.Mainnet; } @@ -73,6 +77,9 @@ export const formatChainIdToDec = ( if (chainId === BtcScope.Mainnet) { return ChainId.BTC; } + if (chainId === XlmScope.Pubnet) { + return ChainId.STELLAR; + } if (chainId === TrxScope.Mainnet) { return ChainId.TRON; } @@ -170,6 +177,15 @@ export const formatAddressToAssetId = ( ); } + if (chainIdCaip === XlmScope.Pubnet) { + const stellarAssetNamespace = addressOrAssetId.includes('-') + ? 'asset' + : 'sep41'; + return CaipAssetTypeStruct.create( + `${chainIdCaip}/${stellarAssetNamespace}:${addressOrAssetId}`, + ); + } + // EVM assets if (!isStrictHexString(addressOrAssetId)) { return undefined; diff --git a/packages/bridge-controller/src/utils/feature-flags.test.ts b/packages/bridge-controller/src/utils/feature-flags.test.ts index b9b00d811f..14c244cc6d 100644 --- a/packages/bridge-controller/src/utils/feature-flags.test.ts +++ b/packages/bridge-controller/src/utils/feature-flags.test.ts @@ -46,6 +46,10 @@ describe('feature-flags', () => { isActiveSrc: true, isActiveDest: true, }, + '20000000000002': { + isActiveSrc: true, + isActiveDest: true, + }, }, chainRanking: [], }; @@ -87,6 +91,10 @@ describe('feature-flags', () => { isActiveSrc: true, isActiveDest: true, }, + 'stellar:pubnet': { + isActiveSrc: true, + isActiveDest: true, + }, }, }); }); @@ -223,6 +231,10 @@ describe('feature-flags', () => { isActiveSrc: true, isActiveDest: true, }, + '20000000000002': { + isActiveSrc: true, + isActiveDest: true, + }, }, }; @@ -312,6 +324,10 @@ describe('feature-flags', () => { isActiveDest: true, isActiveSrc: true, }, + 'stellar:pubnet': { + isActiveDest: true, + isActiveSrc: true, + }, }, }; diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 821c86c4e1..0f78b95cec 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -2,13 +2,14 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import type { - GenericQuoteRequest, - QuoteResponse, - Quote, - NonEvmFees, - L1GasFees, - TxData, +import { + ChainId, + type GenericQuoteRequest, + type QuoteResponse, + type Quote, + type NonEvmFees, + type L1GasFees, + type TxData, } from '../types'; import { isValidQuoteRequest, @@ -149,6 +150,41 @@ describe('Quote Utils', () => { expect(isValidQuoteRequest(requestWithoutSlippage)).toBe(true); }); }); + + it('requires destWalletAddress when bridging to Stellar', () => { + expect( + isValidQuoteRequest({ + ...validRequest, + destChainId: ChainId.STELLAR.toString(), + }), + ).toBe(false); + + expect( + isValidQuoteRequest({ + ...validRequest, + destChainId: ChainId.STELLAR.toString(), + destWalletAddress: + 'GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN', + }), + ).toBe(true); + }); + + it('requires destWalletAddress when bridging from Stellar', () => { + expect( + isValidQuoteRequest({ + ...validRequest, + srcChainId: ChainId.STELLAR.toString(), + }), + ).toBe(false); + + expect( + isValidQuoteRequest({ + ...validRequest, + srcChainId: ChainId.STELLAR.toString(), + destWalletAddress: '0x789', + }), + ).toBe(true); + }); }); }); From 1d1373d55f96cab50606d9ca51c6114c6b88a182 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Wed, 29 Apr 2026 09:27:41 +0200 Subject: [PATCH 2/6] chore: update changelog --- packages/bridge-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 550972f28a..ee4f6d281f 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -14,7 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `quickBuy` and `dappSwap` FeatureIds for external swap quote consumers ([#8598](https://github.com/MetaMask/core/pull/8598)) - **BREAKING:** Add `market_closed` and `quote_expired` QuoteWarning ([#8598](https://github.com/MetaMask/core/pull/8598)) - Add `tokenSecurityTypeDestination: string | null` to `BridgeControllerState` (default `null`), set via `updateBridgeQuoteRequestParams` and reset by `resetState` ([#8595](https://github.com/MetaMask/core/pull/8595)) -- Add Stellar network support for bridge quotes and non-EVM fee calculation (TODO: THIS PR) +- Add Stellar network support for bridge quotes and non-EVM fee calculation ([#8625](https://github.com/MetaMask/core/pull/8625)) ### Changed From 8db5539c29553e17c00b9355c70f468ea403d7af Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Wed, 29 Apr 2026 09:39:03 +0200 Subject: [PATCH 3/6] chore: update changelog --- packages/bridge-controller/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index ee4f6d281f..dad151c7d0 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 Stellar network support for bridge quotes and non-EVM fee calculation ([#8625](https://github.com/MetaMask/core/pull/8625)) + ## [71.0.0] ### Added @@ -14,7 +18,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **BREAKING:** Add `quickBuy` and `dappSwap` FeatureIds for external swap quote consumers ([#8598](https://github.com/MetaMask/core/pull/8598)) - **BREAKING:** Add `market_closed` and `quote_expired` QuoteWarning ([#8598](https://github.com/MetaMask/core/pull/8598)) - Add `tokenSecurityTypeDestination: string | null` to `BridgeControllerState` (default `null`), set via `updateBridgeQuoteRequestParams` and reset by `resetState` ([#8595](https://github.com/MetaMask/core/pull/8595)) -- Add Stellar network support for bridge quotes and non-EVM fee calculation ([#8625](https://github.com/MetaMask/core/pull/8625)) ### Changed From cbe8a98c1e7471bb003b79f91b19c12a0f4d822d Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Wed, 29 Apr 2026 10:01:49 +0200 Subject: [PATCH 4/6] fix: lint --- eslint-suppressions.json | 3 --- .../src/bridge-controller.test.ts | 9 ++++++-- .../bridge-controller/src/utils/bridge.ts | 22 ++++++++++--------- .../bridge-controller/src/utils/quote.test.ts | 16 +++++++------- 4 files changed, 27 insertions(+), 23 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 21f85f7a3c..851c4e9475 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -657,9 +657,6 @@ } }, "packages/bridge-controller/src/utils/bridge.ts": { - "@typescript-eslint/explicit-function-return-type": { - "count": 9 - }, "id-denylist": { "count": 1 } diff --git a/packages/bridge-controller/src/bridge-controller.test.ts b/packages/bridge-controller/src/bridge-controller.test.ts index b38424ec08..69daddcfbb 100644 --- a/packages/bridge-controller/src/bridge-controller.test.ts +++ b/packages/bridge-controller/src/bridge-controller.test.ts @@ -2872,6 +2872,7 @@ describe('BridgeController', function () { }, }; }) as unknown as QuoteResponse[]; + const snapRequestScopes: (string | undefined)[] = []; messengerCallMock.mockImplementation( ( @@ -2914,10 +2915,10 @@ describe('BridgeController', function () { } if (actionType === 'SnapController:handleRequest') { - expect( + snapRequestScopes.push( (params as { request?: { params?: { scope?: string } } }).request ?.params?.scope, - ).toBe(XlmScope.Pubnet); + ); return Promise.resolve([ { type: 'base', @@ -2956,6 +2957,10 @@ describe('BridgeController', function () { expect(quotes).toHaveLength(2); expect(quotes[0].nonEvmFeesInNative).toBe('0.00001'); expect(quotes[1].nonEvmFeesInNative).toBe('0.00001'); + expect(snapRequestScopes).toStrictEqual([ + XlmScope.Pubnet, + XlmScope.Pubnet, + ]); }); }); diff --git a/packages/bridge-controller/src/utils/bridge.ts b/packages/bridge-controller/src/utils/bridge.ts index a85f84e018..4908c21500 100644 --- a/packages/bridge-controller/src/utils/bridge.ts +++ b/packages/bridge-controller/src/utils/bridge.ts @@ -41,7 +41,7 @@ import { export const isCrossChain = ( srcChainId: GenericQuoteRequest['srcChainId'], destChainId?: GenericQuoteRequest['destChainId'], -) => { +): boolean => { try { if (!destChainId) { return false; @@ -115,7 +115,7 @@ export const getNativeAssetForChainId = ( */ export const getEthUsdtResetData = ( destChainId: GenericQuoteRequest['destChainId'], -) => { +): string => { const spenderAddress = isCrossChain(CHAIN_IDS.MAINNET, destChainId) ? METABRIDGE_ETHEREUM_ADDRESS : SWAPS_CONTRACT_ADDRESSES[CHAIN_IDS.MAINNET]; @@ -132,7 +132,7 @@ export const getEthUsdtResetData = ( export const isEthUsdt = ( chainId: GenericQuoteRequest['srcChainId'], address: string, -) => +): boolean => formatChainIdToDec(chainId) === ChainId.ETH && address.toLowerCase() === ETH_USDT_ADDRESS.toLowerCase(); @@ -156,7 +156,7 @@ export const sumHexes = (...hexStrings: string[]): Hex => { export const isSwapsDefaultTokenAddress = ( address: string, chainId: Hex | CaipChainId, -) => { +): boolean => { if (!address || !chainId) { return false; } @@ -175,7 +175,7 @@ export const isSwapsDefaultTokenAddress = ( export const isSwapsDefaultTokenSymbol = ( symbol: string, chainId: Hex | CaipChainId, -) => { +): boolean => { if (!symbol || !chainId) { return false; } @@ -189,7 +189,7 @@ export const isSwapsDefaultTokenSymbol = ( * @param address - The address to check * @returns Whether the address is a native asset */ -export const isNativeAddress = (address?: string | null) => +export const isNativeAddress = (address?: string | null): boolean => address === AddressZero || // bridge and swap apis set the native asset address to zero address === '' || // assets controllers set the native asset address to an empty string !address || @@ -207,7 +207,7 @@ export const isNativeAddress = (address?: string | null) => */ export const isSolanaChainId = ( chainId: Hex | number | CaipChainId | string, -) => { +): boolean => { if (isCaipChainId(chainId)) { return chainId === SolScope.Mainnet.toString(); } @@ -216,14 +216,16 @@ export const isSolanaChainId = ( export const isBitcoinChainId = ( chainId: Hex | number | CaipChainId | string, -) => { +): boolean => { if (isCaipChainId(chainId)) { return chainId === BtcScope.Mainnet.toString(); } return chainId.toString() === ChainId.BTC.toString(); }; -export const isTronChainId = (chainId: Hex | number | CaipChainId | string) => { +export const isTronChainId = ( + chainId: Hex | number | CaipChainId | string, +): boolean => { if (isCaipChainId(chainId)) { return chainId === TrxScope.Mainnet.toString(); } @@ -232,7 +234,7 @@ export const isTronChainId = (chainId: Hex | number | CaipChainId | string) => { export const isStellarChainId = ( chainId: Hex | number | CaipChainId | string, -) => { +): boolean => { if (isCaipChainId(chainId)) { return chainId === XlmScope.Pubnet.toString(); } diff --git a/packages/bridge-controller/src/utils/quote.test.ts b/packages/bridge-controller/src/utils/quote.test.ts index 0f78b95cec..91154cb997 100644 --- a/packages/bridge-controller/src/utils/quote.test.ts +++ b/packages/bridge-controller/src/utils/quote.test.ts @@ -2,14 +2,14 @@ import { AddressZero } from '@ethersproject/constants'; import { convertHexToDecimal } from '@metamask/controller-utils'; import { BigNumber } from 'bignumber.js'; -import { - ChainId, - type GenericQuoteRequest, - type QuoteResponse, - type Quote, - type NonEvmFees, - type L1GasFees, - type TxData, +import { ChainId } from '../types'; +import type { + GenericQuoteRequest, + QuoteResponse, + Quote, + NonEvmFees, + L1GasFees, + TxData, } from '../types'; import { isValidQuoteRequest, From b362781c3be3cff728deeac8659c19e4776b8d92 Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Thu, 30 Apr 2026 10:05:03 +0200 Subject: [PATCH 5/6] chore: only accepts classic asset or soroban contracts as tokens --- .../src/utils/caip-formatters.test.ts | 9 +++++++++ .../src/utils/caip-formatters.ts | 20 +++++++++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/packages/bridge-controller/src/utils/caip-formatters.test.ts b/packages/bridge-controller/src/utils/caip-formatters.test.ts index eff34dd507..7d0233330e 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.test.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.test.ts @@ -316,5 +316,14 @@ describe('CAIP Formatters', () => { `${XlmScope.Pubnet}/sep41:${tokenAddress}`, ); }); + + it('should not create Stellar asset types for bare issuer account addresses', () => { + expect( + formatAddressToAssetId( + 'GDTVUGOC5UHFYH3Y3XECEG7UO7H5PRPI6WPMOMKN5UVBFZNQOKHM4C7I', + ChainId.STELLAR, + ), + ).toBeUndefined(); + }); }); }); diff --git a/packages/bridge-controller/src/utils/caip-formatters.ts b/packages/bridge-controller/src/utils/caip-formatters.ts index 2f5b6b12e4..a38dc53505 100644 --- a/packages/bridge-controller/src/utils/caip-formatters.ts +++ b/packages/bridge-controller/src/utils/caip-formatters.ts @@ -29,6 +29,9 @@ import { isTronChainId, } from './bridge'; +const STELLAR_CLASSIC_ASSET_REGEX = /^[a-zA-Z0-9]{1,12}-G[A-Z2-7]{55}$/u; +const STELLAR_SOROBAN_CONTRACT_REGEX = /^C[A-Z2-7]{55}$/u; + /** * Converts a chainId to a CaipChainId * @@ -178,12 +181,17 @@ export const formatAddressToAssetId = ( } if (chainIdCaip === XlmScope.Pubnet) { - const stellarAssetNamespace = addressOrAssetId.includes('-') - ? 'asset' - : 'sep41'; - return CaipAssetTypeStruct.create( - `${chainIdCaip}/${stellarAssetNamespace}:${addressOrAssetId}`, - ); + if (STELLAR_CLASSIC_ASSET_REGEX.test(addressOrAssetId)) { + return CaipAssetTypeStruct.create( + `${chainIdCaip}/asset:${addressOrAssetId}`, + ); + } + if (STELLAR_SOROBAN_CONTRACT_REGEX.test(addressOrAssetId)) { + return CaipAssetTypeStruct.create( + `${chainIdCaip}/sep41:${addressOrAssetId}`, + ); + } + return undefined; } // EVM assets From 54a48b67014e48425a11ea016bced599e73bd91a Mon Sep 17 00:00:00 2001 From: Julien Fontanel Date: Thu, 30 Apr 2026 10:09:25 +0200 Subject: [PATCH 6/6] fix: remove unwanted hyphen --- packages/bridge-controller/CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/bridge-controller/CHANGELOG.md b/packages/bridge-controller/CHANGELOG.md index 067af3904d..a9d0c67daf 100644 --- a/packages/bridge-controller/CHANGELOG.md +++ b/packages/bridge-controller/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add Stellar network support for bridge quotes and non-EVM fee calculation ([#8625](https://github.com/MetaMask/core/pull/8625)) -- + ### Changed - Bump `@metamask/messenger` from `^1.1.1` to `^1.2.0` ([#8632](https://github.com/MetaMask/core/pull/8632))