Skip to content

Commit 4371caf

Browse files
authored
feat(card): Solana Delegation (MetaMask#25276)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> This PR adds Solana delegation support for MetaMask Card spending limits and updates the UI to reflect multi-network support. **Key changes:** 1. **Solana Delegation Support**: Implements the complete delegation flow for Solana tokens, enabling users to set spending limits on Solana assets (e.g., SOL, USDC on Solana). - Added `completeSolanaDelegation` method in CardSDK for the Solana-specific backend endpoint - Integrated Solana Wallet Snap for message signing (`signCardMessage`) and SPL Token approval transactions (`approveCardAmount`) - Updated `useCardDelegation` hook to handle both EVM and Solana chains 2. **UI Updates**: - Updated the "Other" button in the Spending Limit screen to display Base and Solana network icons alongside the three dots icon - Removed "Solana not supported" warnings and filters from AssetSelectionBottomSheet and SpendingLimit components - Enabled the "Manage Spending Limit" option for all supported networks including Solana ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added Solana delegation support for MetaMask Card spending limits ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: Solana Delegation for Card Spending Limits Scenario: User sets spending limit on Solana token Given user is authenticated with MetaMask Card And user has a Solana account with SOL or USDC balance When user navigates to Spending Limit screen And user selects a Solana token (SOL or USDC) And user chooses "Full access" or sets a custom spending limit And user confirms the spending limit Then user is prompted to sign a message via Solana Wallet Snap And user approves the SPL Token approval transaction And spending limit is successfully set for the Solana token Scenario: User views "Other" networks button Given user is on the Spending Limit screen When user views the asset selection cards Then the "Other" button displays Base and Solana network icons with a three dots icon ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <!-- [screenshots/recordings] --> ## **Pre-merge author checklist** - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I've applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **High Risk** > Adds a new Solana signing + SPL approval flow via the Solana Wallet Snap and changes delegation completion API routing/validation, touching transaction execution and auth-sensitive logic. Bugs here could break spending-limit updates or cause incorrect on-chain approvals across networks. > > **Overview** > Adds **Solana support for card spending-limit delegation** end-to-end: `useCardDelegation` can now sign SIWE-style messages via the Solana Wallet Snap, submit an SPL token approval (`approveCardAmount`), wait for non-EVM confirmation via `MultichainTransactionsController:stateChange`, then complete delegation via the backend. > > Updates the SDK to replace `completeEVMDelegation` with network-aware `completeDelegation` (EVM vs Solana endpoints + format validation), and removes prior Solana gating across the UI (spending-limit screen validation/warnings, asset-selection filtering/footer, and Card Home manage-limit availability). Also refreshes the “Other” asset card to show Base+Solana network icons and bumps `@metamask/solana-wallet-snap` to `^2.7.4`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 11f71a5. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent ab4c4b7 commit 4371caf

23 files changed

Lines changed: 1239 additions & 479 deletions

app/components/UI/Card/Views/CardHome/CardHome.test.tsx

Lines changed: 0 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2278,31 +2278,6 @@ describe('CardHome Component', () => {
22782278
expect(screen.queryByText('Spending Limit')).not.toBeOnTheScreen();
22792279
});
22802280

2281-
it('hides manage spending limit button for Solana chain', () => {
2282-
// Given: authenticated with Solana chain
2283-
setupMockSelectors({ isAuthenticated: true });
2284-
mockIsSolanaChainId.mockReturnValue(true);
2285-
const solanaToken = {
2286-
...mockPriorityToken,
2287-
caipChainId: 'solana:mainnet',
2288-
allowanceState: AllowanceState.Limited,
2289-
};
2290-
setupLoadCardDataMock({
2291-
priorityToken: solanaToken,
2292-
allTokens: [solanaToken],
2293-
isAuthenticated: true,
2294-
warning: null,
2295-
});
2296-
2297-
// When: component renders
2298-
render();
2299-
2300-
// Then: should not display manage spending limit button
2301-
expect(
2302-
screen.queryByTestId(CardHomeSelectors.MANAGE_SPENDING_LIMIT_ITEM),
2303-
).not.toBeOnTheScreen();
2304-
});
2305-
23062281
it('hides close spending limit warning for Solana chain', () => {
23072282
// Given: authenticated with Solana chain and close to limit (15% remaining)
23082283
setupMockSelectors({ isAuthenticated: true });

app/components/UI/Card/Views/CardHome/CardHome.tsx

Lines changed: 15 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1571,23 +1571,21 @@ const CardHome = () => {
15711571
testID="freeze-card-list-item"
15721572
/>
15731573
)}
1574-
{isBaanxLoginEnabled &&
1575-
!isLoading &&
1576-
!isSolanaChainId(priorityToken?.caipChainId ?? '') && (
1577-
<ManageCardListItem
1578-
title={strings(
1579-
'card.card_home.manage_card_options.manage_spending_limit',
1580-
)}
1581-
description={strings(
1582-
priorityToken?.allowanceState === AllowanceState.Enabled
1583-
? 'card.card_home.manage_card_options.manage_spending_limit_description_full'
1584-
: 'card.card_home.manage_card_options.manage_spending_limit_description_restricted',
1585-
)}
1586-
rightIcon={IconName.ArrowRight}
1587-
onPress={manageSpendingLimitAction}
1588-
testID={CardHomeSelectors.MANAGE_SPENDING_LIMIT_ITEM}
1589-
/>
1590-
)}
1574+
{isBaanxLoginEnabled && !isLoading && (
1575+
<ManageCardListItem
1576+
title={strings(
1577+
'card.card_home.manage_card_options.manage_spending_limit',
1578+
)}
1579+
description={strings(
1580+
priorityToken?.allowanceState === AllowanceState.Enabled
1581+
? 'card.card_home.manage_card_options.manage_spending_limit_description_full'
1582+
: 'card.card_home.manage_card_options.manage_spending_limit_description_restricted',
1583+
)}
1584+
rightIcon={IconName.ArrowRight}
1585+
onPress={manageSpendingLimitAction}
1586+
testID={CardHomeSelectors.MANAGE_SPENDING_LIMIT_ITEM}
1587+
/>
1588+
)}
15911589
</Box>
15921590
{!isLoading &&
15931591
!cardSetupState.isKYCPending &&

app/components/UI/Card/Views/SpendingLimit/SpendingLimit.test.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -317,7 +317,6 @@ describe('SpendingLimit Component', () => {
317317
cancel: mockCancel,
318318
skip: mockSkip,
319319
isValid: true,
320-
isSolanaSelected: false,
321320
needsFaucet: false,
322321
isFaucetCheckLoading: false,
323322
});
@@ -576,7 +575,6 @@ describe('SpendingLimit Component', () => {
576575
cancel: mockCancel,
577576
skip: mockSkip,
578577
isValid: true,
579-
isSolanaSelected: false,
580578
needsFaucet: false,
581579
isFaucetCheckLoading: false,
582580
});
@@ -692,7 +690,6 @@ describe('SpendingLimit Component', () => {
692690
cancel: mockCancel,
693691
skip: mockSkip,
694692
isValid: true,
695-
isSolanaSelected: false,
696693
needsFaucet: false,
697694
isFaucetCheckLoading: false,
698695
});
@@ -736,7 +733,6 @@ describe('SpendingLimit Component', () => {
736733
cancel: mockCancel,
737734
skip: mockSkip,
738735
isValid: true,
739-
isSolanaSelected: false,
740736
needsFaucet: false,
741737
isFaucetCheckLoading: false,
742738
});
@@ -858,7 +854,6 @@ describe('SpendingLimit Component', () => {
858854
cancel: mockCancel,
859855
skip: mockSkip,
860856
isValid: true,
861-
isSolanaSelected: false,
862857
needsFaucet: false,
863858
isFaucetCheckLoading: false,
864859
});
@@ -1071,7 +1066,6 @@ describe('SpendingLimit Component', () => {
10711066
cancel: mockCancel,
10721067
skip: mockSkip,
10731068
isValid: true,
1074-
isSolanaSelected: false,
10751069
needsFaucet: false,
10761070
isFaucetCheckLoading: false,
10771071
});

app/components/UI/Card/Views/SpendingLimit/SpendingLimit.tsx

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,6 @@ const SpendingLimit: React.FC<SpendingLimitProps> = ({ route }) => {
119119
cancel,
120120
skip,
121121
isValid,
122-
isSolanaSelected,
123122
} = useSpendingLimit({
124123
flow,
125124
initialToken: selectedTokenFromRoute,
@@ -328,26 +327,6 @@ const SpendingLimit: React.FC<SpendingLimitProps> = ({ route }) => {
328327

329328
{/* Footer Buttons */}
330329
<Box twClassName="gap-3 mt-6">
331-
{/* Solana Warning */}
332-
{isSolanaSelected && (
333-
<Box
334-
flexDirection={BoxFlexDirection.Row}
335-
twClassName="p-3 bg-warning-muted rounded-lg items-center"
336-
>
337-
<Icon
338-
name={IconName.Info}
339-
size={IconSize.Sm}
340-
color={IconColor.WarningDefault}
341-
/>
342-
<Text
343-
variant={TextVariant.BodySm}
344-
twClassName="flex-1 ml-2 text-warning-default"
345-
>
346-
{strings('card.card_spending_limit.solana_not_supported')}
347-
</Text>
348-
</Box>
349-
)}
350-
351330
<Box flexDirection={BoxFlexDirection.Row} twClassName="gap-3">
352331
<Box twClassName="flex-1">
353332
<Button

app/components/UI/Card/Views/SpendingLimit/components/AssetCard.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import {
66
TextVariant,
77
BoxAlignItems,
88
BoxJustifyContent,
9+
BoxFlexDirection,
910
Icon,
1011
IconName,
1112
IconSize,
1213
IconColor,
1314
} from '@metamask/design-system-react-native';
1415
import { useTailwind } from '@metamask/design-system-twrnc-preset';
1516
import AvatarToken from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarToken';
17+
import AvatarNetwork from '../../../../../../component-library/components/Avatars/Avatar/variants/AvatarNetwork';
1618
import { AvatarSize } from '../../../../../../component-library/components/Avatars/Avatar';
1719
import BadgeWrapper, {
1820
BadgePosition,
@@ -24,6 +26,8 @@ import { NetworkBadgeSource } from '../../../../AssetOverview/Balance/Balance';
2426
import { buildTokenIconUrl } from '../../../util/buildTokenIconUrl';
2527
import { LINEA_CAIP_CHAIN_ID } from '../../../util/buildTokenList';
2628
import { safeFormatChainIdToHex } from '../../../util/safeFormatChainIdToHex';
29+
import { getNetworkImageSource } from '../../../../../../util/networks';
30+
import { cardNetworkInfos } from '../../../constants';
2731

2832
export interface AssetCardProps {
2933
/** Token symbol (e.g., 'mUSD', 'USDC') or 'Other' */
@@ -102,11 +106,40 @@ const AssetCard: React.FC<AssetCardProps> = ({
102106
</BadgeWrapper>
103107
)}
104108
{isOther && (
105-
<Icon
106-
name={IconName.MoreHorizontal}
107-
size={IconSize.Lg}
108-
color={isSelected ? IconColor.IconMuted : IconColor.IconDefault}
109-
/>
109+
<Box
110+
flexDirection={BoxFlexDirection.Row}
111+
alignItems={BoxAlignItems.Center}
112+
>
113+
<AvatarNetwork
114+
size={AvatarSize.Sm}
115+
name="Base"
116+
imageSource={getNetworkImageSource({
117+
chainId: cardNetworkInfos.base.caipChainId,
118+
})}
119+
style={tw.style('rounded-full overflow-hidden')}
120+
/>
121+
<AvatarNetwork
122+
size={AvatarSize.Sm}
123+
name="Solana"
124+
imageSource={getNetworkImageSource({
125+
chainId: cardNetworkInfos.solana.caipChainId,
126+
})}
127+
style={tw.style('-ml-2 rounded-full overflow-hidden')}
128+
/>
129+
<Box
130+
alignItems={BoxAlignItems.Center}
131+
justifyContent={BoxJustifyContent.Center}
132+
style={tw.style(
133+
'w-6 h-6 -ml-2 rounded-full bg-background-default',
134+
)}
135+
>
136+
<Icon
137+
name={IconName.MoreHorizontal}
138+
size={IconSize.Xs}
139+
color={IconColor.PrimaryDefault}
140+
/>
141+
</Box>
142+
</Box>
110143
)}
111144

112145
<Text

app/components/UI/Card/components/AssetSelectionBottomSheet/AssetSelectionBottomSheet.test.tsx

Lines changed: 3 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -209,7 +209,6 @@ const setupComponent = (paramsOverrides = {}) => {
209209
navigateToCardHomeOnPriorityToken: false,
210210
selectionOnly: false,
211211
onTokenSelect: undefined,
212-
hideSolanaAssets: false,
213212
callerRoute: undefined,
214213
callerParams: undefined,
215214
...paramsOverrides,
@@ -267,7 +266,6 @@ describe('AssetSelectionBottomSheet', () => {
267266
navigateToCardHomeOnPriorityToken: false,
268267
selectionOnly: false,
269268
onTokenSelect: undefined,
270-
hideSolanaAssets: false,
271269
callerRoute: undefined,
272270
callerParams: undefined,
273271
});
@@ -372,7 +370,7 @@ describe('AssetSelectionBottomSheet', () => {
372370
});
373371

374372
describe('token filtering', () => {
375-
it('filters out Solana tokens when hideSolanaAssets is true', () => {
373+
it('shows Solana tokens', () => {
376374
const solanaToken = createMockToken({
377375
symbol: 'SOL',
378376
caipChainId: SolScope.Mainnet,
@@ -383,30 +381,13 @@ describe('AssetSelectionBottomSheet', () => {
383381
});
384382
const delegationSettings = createMockDelegationSettings();
385383

386-
const { getByText, queryByText } = setupComponent({
387-
tokensWithAllowances: [solanaToken, lineaToken],
388-
delegationSettings,
389-
hideSolanaAssets: true,
390-
});
391-
392-
expect(getByText(/USDC on/)).toBeOnTheScreen();
393-
expect(queryByText(/SOL on/)).toBeNull();
394-
});
395-
396-
it('shows Solana tokens when hideSolanaAssets is false', () => {
397-
const solanaToken = createMockToken({
398-
symbol: 'SOL',
399-
caipChainId: SolScope.Mainnet,
400-
});
401-
const delegationSettings = createMockDelegationSettings();
402-
403384
const { getByText } = setupComponent({
404-
tokensWithAllowances: [solanaToken],
385+
tokensWithAllowances: [solanaToken, lineaToken],
405386
delegationSettings,
406-
hideSolanaAssets: false,
407387
});
408388

409389
expect(getByText(/SOL on/)).toBeOnTheScreen();
390+
expect(getByText(/USDC on/)).toBeOnTheScreen();
410391
});
411392
});
412393

@@ -891,52 +872,6 @@ describe('AssetSelectionBottomSheet', () => {
891872
});
892873
});
893874

894-
describe('Solana not supported footer', () => {
895-
it('displays Solana not supported button when hideSolanaAssets is true', () => {
896-
const token = createMockToken();
897-
const delegationSettings = createMockDelegationSettings();
898-
899-
const { getByText } = setupComponent({
900-
tokensWithAllowances: [token],
901-
delegationSettings,
902-
hideSolanaAssets: true,
903-
});
904-
905-
expect(getByText('Others tokens on Solana')).toBeOnTheScreen();
906-
expect(getByText('Enable on card.metamask.io')).toBeOnTheScreen();
907-
});
908-
909-
it('calls navigateToCardPage when Solana not supported button is pressed', () => {
910-
const token = createMockToken();
911-
const delegationSettings = createMockDelegationSettings();
912-
913-
const { getByText } = setupComponent({
914-
tokensWithAllowances: [token],
915-
delegationSettings,
916-
hideSolanaAssets: true,
917-
});
918-
919-
fireEvent.press(getByText('Others tokens on Solana'));
920-
921-
expect(mockNavigateToCardPage).toHaveBeenCalled();
922-
});
923-
924-
it('does not display Solana not supported button when hideSolanaAssets is false', () => {
925-
const token = createMockToken();
926-
const delegationSettings = createMockDelegationSettings();
927-
928-
const { queryByText } = setupComponent({
929-
tokensWithAllowances: [token],
930-
delegationSettings,
931-
hideSolanaAssets: false,
932-
});
933-
934-
expect(
935-
queryByText('card.asset_selection.solana_not_supported_button_title'),
936-
).toBeNull();
937-
});
938-
});
939-
940875
describe('wallet address display', () => {
941876
it('displays truncated wallet address when available', () => {
942877
const token = createMockToken({

0 commit comments

Comments
 (0)