Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7f345c7
feat: updates snap methods to use new implementations in bridge contr…
ghgoodreau Sep 3, 2025
d488050
chore: prettier
ghgoodreau Sep 3, 2025
299d7f3
fix: test
ghgoodreau Sep 3, 2025
e6b2bd5
chore: lint
ghgoodreau Sep 3, 2025
9da14ea
test updates
ghgoodreau Sep 3, 2025
98af283
test updates
ghgoodreau Sep 3, 2025
5dc551b
update tests
ghgoodreau Sep 4, 2025
249acca
cleanup
ghgoodreau Sep 4, 2025
eadbd8f
more test coverage
ghgoodreau Sep 4, 2025
58cef05
fix final lint issue
ghgoodreau Sep 4, 2025
f35d3a2
update changelog
ghgoodreau Sep 4, 2025
ca9c3d4
Merge branch 'main' into SWAPS-2839-update-snap-methods-in-bridge-con…
ghgoodreau Sep 8, 2025
cae0407
update computeFee
ghgoodreau Sep 9, 2025
7651b43
snapshot update
ghgoodreau Sep 9, 2025
602645b
fix tests
ghgoodreau Sep 9, 2025
0c1028a
move signandsend to status controller
ghgoodreau Sep 10, 2025
f2d9198
Merge branch 'main' into SWAPS-2839-update-snap-methods-in-bridge-con…
ghgoodreau Sep 11, 2025
d2e73fc
PR comments
ghgoodreau Sep 11, 2025
45231de
remove keyring from lock
ghgoodreau Sep 11, 2025
30c24ec
more nonevm fee usage
ghgoodreau Sep 11, 2025
5ac13fa
update tests
ghgoodreau Sep 11, 2025
2e01ad7
update units
ghgoodreau Sep 11, 2025
b8815c6
bitcoin validation for quotes
ghgoodreau Sep 17, 2025
acc60f5
remove solana fee and rename to nonevmfees
ghgoodreau Sep 17, 2025
644f6d3
fix: merge conflicts
ghgoodreau Sep 17, 2025
81f4d09
yarn install
ghgoodreau Sep 17, 2025
ea57895
test fixes
ghgoodreau Sep 17, 2025
9e9ba9a
more test updates
ghgoodreau Sep 17, 2025
beb089d
changelog updates
ghgoodreau Sep 17, 2025
3917e27
remove tron changes
ghgoodreau Sep 19, 2025
1af634d
update function naming
ghgoodreau Sep 19, 2025
41ce980
add TODO for chainID handling in activity for bitcoin
ghgoodreau Sep 19, 2025
fb63f06
update test for new fallback
ghgoodreau Sep 19, 2025
959ddbd
removes redundant conversion of fees for solana
ghgoodreau Sep 22, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/bridge-controller/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Export `isNonEvmChainId` utility function to check for non-EVM chains (Solana, Bitcoin, Tron) ([#6454](https://github.com/MetaMask/core/pull/6454))

### Changed

- 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 (handled via onClientRequest)
- Update fee format to return native units (e.g., SOL) instead of smallest units (e.g., Lamports)
- Add support for Tron chain alongside existing Bitcoin and Solana support
- Export `signAndSendTransactionRequest` utility function
- Fix precision issues in Solana fee conversion using BigInt math
- **BREAKING:** Bump peer dependency `@metamask/assets-controller` from `^74.0.0` to `^75.0.0` ([#6570](https://github.com/MetaMask/core/pull/6570))
- Bump `@metamask/keyring-api` from `^20.1.0` to `^21.0.0` ([#6560](https://github.com/MetaMask/core/pull/6560))

Expand Down

Large diffs are not rendered by default.

158 changes: 158 additions & 0 deletions packages/bridge-controller/src/bridge-controller.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
/* eslint-disable jest/no-conditional-in-test */
import { Contract } from '@ethersproject/contracts';
import {
BtcScope,
EthAccountType,
EthScope,
SolAccountType,
Expand Down Expand Up @@ -588,6 +589,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);
Expand Down Expand Up @@ -1678,6 +1699,28 @@ 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.${expectedFees.padStart(9, '0')}`
: '0', // Convert lamports to SOL
fungible: true,
},
},
]);
}, 100);
}
return setTimeout(() => {
resolve({ value: expectedFees });
}, 100);
Expand Down Expand Up @@ -1780,6 +1823,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<BridgeControllerMessenger['call']>
): ReturnType<BridgeControllerMessenger['call']> => {
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].solanaFeesInLamports).toBe('0.00005'); // BTC fee as-is
expect(quotes[1].solanaFeesInLamports).toBe('0.00005'); // BTC fee as-is
});

describe('trackUnifiedSwapBridgeEvent client-side calls', () => {
beforeEach(() => {
jest.clearAllMocks();
Expand Down
62 changes: 50 additions & 12 deletions packages/bridge-controller/src/bridge-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import { hasSufficientBalance } from './utils/balance';
import {
getDefaultBridgeControllerState,
isCrossChain,
isNonEvmChainId,
isSolanaChainId,
sumHexes,
} from './utils/bridge';
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -727,51 +728,88 @@ export class BridgeController extends StaticIntervalPollingController<BridgePoll
: undefined;
};

/**
* Appends transaction fees for non-EVM chains to quotes
*
* @param quotes - Array of quote responses to append fees to
* @returns Array of quotes with fees appended, or undefined if quotes are for EVM chains
*/
readonly #appendSolanaFees = async (
quotes: QuoteResponse[],
): Promise<(QuoteResponse & SolanaFees)[] | undefined> => {
// Return early if some of the quotes are not for solana
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');
let feeInNative = '0';

if (baseFee?.asset?.amount) {
if (isSolanaChainId(quote.srcChainId)) {
// Convert SOL to Lamports (1 SOL = 10^9 Lamports)
// Use string manipulation to avoid floating point precision issues
const parts = baseFee.asset.amount.split('.');
const wholePart = parts[0] || '0';
const decimalPart = (parts[1] || '').padEnd(9, '0').slice(0, 9);
feeInNative = BigInt(wholePart + decimalPart).toString();
} else {
// For other chains (BTC, Tron), use the fee as-is
feeInNative = baseFee.asset.amount;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should make this generic across chains so the metadata calculation can work for any non-EVM chain. If you search the package for solanaFeesInLamports you can see how it's used. We could deprecate that and add a new metadata field for feeInNative

Looks like network fees for Tron and BTC are not getting included in the metadata (see getBridgeQuotes selector)

}
}

return {
...quoteResponse,
solanaFeesInLamports: fees,
solanaFeesInLamports: feeInNative,
};
}
return quoteResponse;
}),
);

const quotesWithSolanaFees = (await solanaFeePromises).reduce<
const quotesWithNonEvmFees = (await nonEvmFeePromises).reduce<
(QuoteResponse & SolanaFees)[]
>((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() {
Expand Down
1 change: 1 addition & 0 deletions packages/bridge-controller/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export {
isNativeAddress,
isSolanaChainId,
isBitcoinChainId,
isNonEvmChainId,
getNativeAssetForChainId,
getDefaultBridgeControllerState,
isCrossChain,
Expand Down
41 changes: 41 additions & 0 deletions packages/bridge-controller/src/selectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
selectBridgeQuotes,
selectIsQuoteExpired,
selectBridgeFeatureFlags,
selectMinimumBalanceForRentExemptionInSOL,
} from './selectors';
import type { BridgeAsset, QuoteResponse } from './types';
import { SortOrder, RequestStatus, ChainId } from './types';
Expand Down Expand Up @@ -1113,4 +1114,44 @@ 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');
});
});
});
1 change: 1 addition & 0 deletions packages/bridge-controller/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,7 @@ export enum ChainId {
LINEA = 59144,
SOLANA = 1151111081099710,
BTC = 20000000000001,
TRON = 728126428, // Tron mainnet chain ID
}

export type FeatureFlagsPlatformConfig = Infer<typeof PlatformConfigSchema>;
Expand Down
Loading
Loading