Skip to content

Commit 7274b18

Browse files
authored
fix(card): missing tokens when revoking using external tools (#30209)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** This branch fixes MetaMask Card **available funding assets** so that after a user revokes a token allowance (e.g. via revoke.cash) or when another linked wallet still holds an active delegation, the **currently selected wallet** still gets a correct **Inactive / not enabled** row for supported tokens from Baanx delegation settings—instead of the token disappearing or being incorrectly deduplicated across wallets. **What changed (high level):** 1. **`BaanxProvider.buildSupportedTokens`** — Replaces wallet-blind deduplication (`address` + `chainId` only) with **`hasPlaceholderForCurrentWallet`**: an existing row only blocks adding an Inactive placeholder for the **same** wallet (or legacy empty `walletAddress`). Another wallet’s Active/Limited row no longer suppresses the current wallet’s placeholder. New Inactive placeholders are stamped with **`walletAddress: currentWalletAddress`** (passed from `getCardHomeData`). 2. **`selectCardAvailableTokens`** — **Active** and **Limited** assets from any linked wallet stay visible; **Inactive** rows are shown only when they belong to the **selected EVM account** (or have no `walletAddress`), avoiding duplicate “not enabled” noise for other accounts. 3. **Tests** — `BaanxProvider.test.ts` covers `buildSupportedTokens` (empty funding list, same-wallet dedup, multi-wallet core case, null / empty `delegationSettings`, contract enrichment). `cardController.test.ts` covers the selector filtering behaviour with mocked selected account. **Intentionally not included:** A feature-flag fallback that synthesizes tokens without `delegationSettings` (would lack jurisdiction-correct `delegationContract`). When `delegationSettings` is missing or has no `networks`, behaviour stays **return `fundingAssets` as-is** (no synthetic list). ### Why - **`/v1/wallet/external`** can be empty after revocation while **`/v1/delegation/chain/config`** still lists supported networks/tokens. Placeholders must be built from delegation settings **per selected wallet**, not deduplicated globally across wallets. - **UI** reads **`availableFundingAssets`** via **`selectCardAvailableTokens`**; filtering Inactive by account keeps the asset list accurate for account switching and multi-wallet Baanx linkage. ### What changed (scoped paths) | Area | Files / behaviour | | ---- | ----------------- | | **Baanx provider** | [`BaanxProvider.ts`](app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts): `getCardHomeData(address, …)` passes `address` into `buildSupportedTokens`; wallet-aware placeholder dedup; Inactive `walletAddress` set to current address. | | **Selectors** | [`cardController.ts`](app/selectors/cardController.ts): `selectCardAvailableTokens` uses `selectSelectedEvmAccount` and filters Inactive by current address. | | **Tests** | [`BaanxProvider.test.ts`](app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts), [`cardController.test.ts`](app/selectors/cardController.test.ts). | ### Out of scope (intentional) - Feature-flag–only token list without `delegationSettings` / `delegationContract`. - Changes to unauthenticated `getOnChainAssets` (still bypasses `buildSupportedTokens`). ## **Changelog** CHANGELOG entry: Fixed Card available asset list so supported tokens show per-wallet “not enabled” state after revocation or when another linked wallet still has an active delegation; inactive rows are scoped to the selected account in the token picker. ## **Related issues** Fixes: <!-- Add ticket ID(s), e.g. Fixes: MUSD-xxx or #12345 --> ## **Manual testing steps** ```gherkin Feature: Card available tokens after revocation / multi-wallet Background: Given I am authenticated with the MetaMask Card (Baanx) backend And delegation chain config returns supported networks (e.g. Linea USDC) And I may have more than one EVM wallet linked to the same card account Scenario: revoke on external tool then open Card Given I had delegated USDC on Linea from wallet A When I revoke the allowance (e.g. revoke.cash) so wallet external API returns no rows for that delegation And I select wallet A in MetaMask When I open Card home / spending limit asset list Then USDC on Linea still appears as not enabled (Inactive) for wallet A when delegation settings still list the token And I can re-enable delegation from that row (contract comes from delegation settings) Scenario: another wallet still delegated Given wallet B still has an active USDC Linea delegation in Baanx wallet external data When I select wallet A (revoked or never delegated) Then I still see a not-enabled / Inactive row for USDC Linea for wallet A And I still see wallet B’s Active (or Limited) row for awareness Scenario: account switch Given both wallets have their own Inactive placeholders for the same token When I switch the selected EVM account in the app Then the available token list shows Inactive rows only for the selected account (plus all Active/Limited from any wallet) ``` ## **Screenshots/Recordings** ### **Before** <!-- Token missing from Card asset list after revoke, or wrong wallet’s row only. --> ### **After** <!-- Same token visible as not enabled for current wallet; other wallet’s active row still visible if applicable. --> ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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] > **Medium Risk** > Changes multi-wallet token placeholder and filtering logic for Card funding assets, which can affect what users see and can re-enable funding; risk is mitigated by added unit coverage but may impact edge cases around wallet selection and legacy empty `walletAddress` rows. > > **Overview** > Fixes Card `availableFundingAssets` generation and display for multi-wallet scenarios so a supported token can still appear as *Inactive/not enabled* for the **currently selected wallet** even if another linked wallet has an Active/Limited entry or the external-wallet API returns no rows. > > `BaanxProvider.getCardHomeData` now passes the current `address` into `buildSupportedTokens`, which dedupes placeholders by `address + chainId + walletAddress` (with a legacy fallback for empty wallet) and stamps new inactive placeholders with the current wallet’s address while still enriching existing assets with the network `delegationContract`. > > `selectCardAvailableTokens` now filters `Inactive` rows to the selected EVM account (or empty `walletAddress`) while always showing Active/Limited rows from any linked wallet, reducing duplicate “not enabled” entries; new tests cover the wallet-aware placeholder and selector filtering behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 730da51. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 0b1fa04 commit 7274b18

4 files changed

Lines changed: 330 additions & 12 deletions

File tree

app/core/Engine/controllers/card-controller/providers/BaanxProvider.test.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -378,4 +378,190 @@ describe('BaanxProvider', () => {
378378
expect(message).not.toContain('Expiration Time');
379379
});
380380
});
381+
382+
describe('buildSupportedTokens', () => {
383+
const LINEA_CHAIN_ID = 'eip155:59144';
384+
const USDC_LINEA_ADDRESS = '0x176211869cA2b568f2A7D4EE941E073a821EE1ff';
385+
const WETH_LINEA_ADDRESS = '0xweth';
386+
const DELEGATION_CONTRACT = '0xdelegation';
387+
388+
const delegationSettings = {
389+
networks: [
390+
{
391+
network: 'linea',
392+
chainId: '0xe708',
393+
environment: 'production',
394+
delegationContract: DELEGATION_CONTRACT,
395+
tokens: {
396+
usdc: {
397+
symbol: 'USDC',
398+
address: USDC_LINEA_ADDRESS,
399+
decimals: 6,
400+
},
401+
},
402+
},
403+
],
404+
};
405+
406+
const buildSupportedTokens = (
407+
provider: BaanxProvider,
408+
fundingAssets: CardFundingAsset[],
409+
settings: typeof delegationSettings | null,
410+
walletAddress = '',
411+
) =>
412+
(
413+
provider as unknown as {
414+
buildSupportedTokens: (
415+
assets: CardFundingAsset[],
416+
settings: typeof delegationSettings | null,
417+
walletAddress: string,
418+
) => CardFundingAsset[];
419+
}
420+
).buildSupportedTokens(fundingAssets, settings, walletAddress);
421+
422+
it('adds Inactive placeholder from delegationSettings when fundingAssets is empty', () => {
423+
const provider = new BaanxProvider({ service: {} as BaanxService });
424+
const result = buildSupportedTokens(
425+
provider,
426+
[],
427+
delegationSettings,
428+
'0xwalletA',
429+
);
430+
431+
const usdc = result.find(
432+
(a) =>
433+
a.address?.toLowerCase() === USDC_LINEA_ADDRESS.toLowerCase() &&
434+
a.chainId === LINEA_CHAIN_ID,
435+
);
436+
expect(usdc).toBeDefined();
437+
expect(usdc?.status).toBe(FundingAssetStatus.Inactive);
438+
expect(usdc?.walletAddress).toBe('0xwalletA');
439+
expect(usdc?.delegationContract).toBe(DELEGATION_CONTRACT);
440+
});
441+
442+
it('preserves Active status and does not add a duplicate for the same wallet', () => {
443+
const activeAsset: CardFundingAsset = {
444+
symbol: 'USDC',
445+
name: 'USD Coin',
446+
address: USDC_LINEA_ADDRESS,
447+
walletAddress: '0xwalletA',
448+
decimals: 6,
449+
chainId: LINEA_CHAIN_ID,
450+
spendableBalance: '100',
451+
spendingCap: '1000',
452+
priority: 1,
453+
status: FundingAssetStatus.Active,
454+
};
455+
456+
const provider = new BaanxProvider({ service: {} as BaanxService });
457+
const result = buildSupportedTokens(
458+
provider,
459+
[activeAsset],
460+
delegationSettings,
461+
'0xwalletA',
462+
);
463+
464+
const usdcEntries = result.filter(
465+
(a) =>
466+
a.address?.toLowerCase() === USDC_LINEA_ADDRESS.toLowerCase() &&
467+
a.chainId === LINEA_CHAIN_ID,
468+
);
469+
expect(usdcEntries).toHaveLength(1);
470+
expect(usdcEntries[0].status).toBe(FundingAssetStatus.Active);
471+
});
472+
473+
it('adds an Inactive placeholder for walletA even when walletB already has an Active entry', () => {
474+
const walletBActive: CardFundingAsset = {
475+
symbol: 'USDC',
476+
name: 'USD Coin',
477+
address: USDC_LINEA_ADDRESS,
478+
walletAddress: '0xwalletB',
479+
decimals: 6,
480+
chainId: LINEA_CHAIN_ID,
481+
spendableBalance: '100',
482+
spendingCap: '1000',
483+
priority: 1,
484+
status: FundingAssetStatus.Active,
485+
};
486+
487+
const provider = new BaanxProvider({ service: {} as BaanxService });
488+
const result = buildSupportedTokens(
489+
provider,
490+
[walletBActive],
491+
delegationSettings,
492+
'0xwalletA',
493+
);
494+
495+
const walletAEntry = result.find(
496+
(a) =>
497+
a.address?.toLowerCase() === USDC_LINEA_ADDRESS.toLowerCase() &&
498+
a.chainId === LINEA_CHAIN_ID &&
499+
a.walletAddress === '0xwalletA',
500+
);
501+
expect(walletAEntry).toBeDefined();
502+
expect(walletAEntry?.status).toBe(FundingAssetStatus.Inactive);
503+
504+
// walletB Active entry must still be present
505+
const walletBEntry = result.find((a) => a.walletAddress === '0xwalletB');
506+
expect(walletBEntry?.status).toBe(FundingAssetStatus.Active);
507+
});
508+
509+
it('returns fundingAssets unchanged when delegationSettings is null', () => {
510+
const provider = new BaanxProvider({ service: {} as BaanxService });
511+
const asset: CardFundingAsset = {
512+
symbol: 'WETH',
513+
name: 'Wrapped Ether',
514+
address: WETH_LINEA_ADDRESS,
515+
walletAddress: '0xwalletA',
516+
decimals: 18,
517+
chainId: LINEA_CHAIN_ID,
518+
spendableBalance: '1',
519+
spendingCap: '10',
520+
priority: 1,
521+
status: FundingAssetStatus.Active,
522+
};
523+
524+
const result = buildSupportedTokens(provider, [asset], null, '0xwalletA');
525+
526+
expect(result).toHaveLength(1);
527+
expect(result[0]).toStrictEqual(asset);
528+
});
529+
530+
it('returns fundingAssets unchanged when delegationSettings has no networks', () => {
531+
const provider = new BaanxProvider({ service: {} as BaanxService });
532+
const result = buildSupportedTokens(
533+
provider,
534+
[],
535+
{ networks: [] },
536+
'0xwalletA',
537+
);
538+
expect(result).toHaveLength(0);
539+
});
540+
541+
it('enriches existing assets with delegationContract from matching network', () => {
542+
const assetWithoutContract: CardFundingAsset = {
543+
symbol: 'USDC',
544+
name: 'USD Coin',
545+
address: USDC_LINEA_ADDRESS,
546+
walletAddress: '0xwalletA',
547+
decimals: 6,
548+
chainId: LINEA_CHAIN_ID,
549+
spendableBalance: '100',
550+
spendingCap: '1000',
551+
priority: 1,
552+
status: FundingAssetStatus.Active,
553+
};
554+
555+
const provider = new BaanxProvider({ service: {} as BaanxService });
556+
const result = buildSupportedTokens(
557+
provider,
558+
[assetWithoutContract],
559+
delegationSettings,
560+
'0xwalletA',
561+
);
562+
563+
const usdc = result.find((a) => a.walletAddress === '0xwalletA');
564+
expect(usdc?.delegationContract).toBe(DELEGATION_CONTRACT);
565+
});
566+
});
381567
});

app/core/Engine/controllers/card-controller/providers/BaanxProvider.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -368,7 +368,7 @@ export class BaanxProvider implements ICardProvider {
368368
// -- Card Home Data --
369369

370370
async getCardHomeData(
371-
_address: string,
371+
address: string,
372372
tokens: CardAuthTokens,
373373
): Promise<CardHomeData> {
374374
try {
@@ -456,6 +456,7 @@ export class BaanxProvider implements ICardProvider {
456456
const availableFundingAssets = this.buildSupportedTokens(
457457
fundingAssets,
458458
delegationSettings,
459+
address,
459460
);
460461

461462
return {
@@ -1532,17 +1533,37 @@ export class BaanxProvider implements ICardProvider {
15321533

15331534
/**
15341535
* Merges user's delegated assets with all tokens from delegation settings.
1535-
* Tokens already in `assets` (matched by token contract address + chainId) are kept.
1536-
* Additional tokens from settings are added as inactive with zero balance.
1536+
* Tokens already in `assets` (matched by token contract address + chainId + wallet) are kept.
1537+
* Additional tokens from settings are added as inactive with zero balance, stamped with
1538+
* `currentWalletAddress` so each linked wallet gets its own placeholder entry.
15371539
*/
15381540
private buildSupportedTokens(
15391541
fundingAssets: CardFundingAsset[],
15401542
delegationSettings: DelegationSettingsResponse | null,
1543+
currentWalletAddress: string = '',
15411544
): CardFundingAsset[] {
15421545
const result = [...fundingAssets];
15431546

15441547
if (!delegationSettings?.networks) return result;
15451548

1549+
const currentAddressLower = currentWalletAddress.toLowerCase();
1550+
// Returns true when a placeholder already exists for the current wallet (or
1551+
// generically). Entries owned by other wallets do NOT count — this account
1552+
// can still enable the token independently.
1553+
// When no current address is known, any existing entry counts (legacy fallback).
1554+
const hasPlaceholderForCurrentWallet = (
1555+
tokenAddress: string,
1556+
chainId: string,
1557+
) =>
1558+
result.some(
1559+
(a) =>
1560+
a.address?.toLowerCase() === tokenAddress.toLowerCase() &&
1561+
a.chainId === chainId &&
1562+
(!currentAddressLower ||
1563+
a.walletAddress?.toLowerCase() === currentAddressLower ||
1564+
!a.walletAddress),
1565+
);
1566+
15461567
for (const network of delegationSettings.networks) {
15471568
const networkName = network.network?.toLowerCase() as string;
15481569
if (
@@ -1567,12 +1588,9 @@ export class BaanxProvider implements ICardProvider {
15671588
for (const tokenConfig of Object.values(network.tokens ?? {})) {
15681589
if (!tokenConfig.address) continue;
15691590

1570-
const alreadyExists = result.some(
1571-
(a) =>
1572-
a.address?.toLowerCase() === tokenConfig.address.toLowerCase() &&
1573-
a.chainId === chainId,
1574-
);
1575-
if (alreadyExists) continue;
1591+
if (hasPlaceholderForCurrentWallet(tokenConfig.address, chainId)) {
1592+
continue;
1593+
}
15761594

15771595
const chainTokens =
15781596
this.cardFeatureFlag?.chains?.[chainId]?.tokens ?? [];
@@ -1587,7 +1605,7 @@ export class BaanxProvider implements ICardProvider {
15871605
isNonProduction && sdkToken?.address
15881606
? sdkToken.address
15891607
: tokenConfig.address,
1590-
walletAddress: '',
1608+
walletAddress: currentWalletAddress,
15911609
decimals: tokenConfig.decimals,
15921610
chainId,
15931611
spendableBalance: '0',

app/selectors/cardController.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -403,6 +403,41 @@ describe('selectCardPrimaryToken', () => {
403403
});
404404

405405
describe('selectCardAvailableTokens', () => {
406+
const WALLET_A = '0xwalletA000000000000000000000000000000001';
407+
const WALLET_B = '0xwalletB000000000000000000000000000000002';
408+
409+
const makeAsset = (
410+
overrides: Partial<(typeof mockCardHomeData.availableFundingAssets)[0]>,
411+
) => ({ ...mockPrimaryAsset, ...overrides });
412+
413+
const stateWithAssets = (
414+
assets: typeof mockCardHomeData.availableFundingAssets,
415+
currentWallet?: string,
416+
) => {
417+
if (currentWallet) {
418+
mockSelectSelectedInternalAccountByScope.mockReturnValue(
419+
jest.fn().mockReturnValue({ address: currentWallet }),
420+
);
421+
} else {
422+
mockSelectSelectedInternalAccountByScope.mockReturnValue(
423+
jest.fn().mockReturnValue(undefined),
424+
);
425+
}
426+
return createMockRootState({
427+
cardHomeData: {
428+
...mockCardHomeData,
429+
availableFundingAssets: assets,
430+
} as unknown as CardControllerState['cardHomeData'],
431+
});
432+
};
433+
434+
beforeEach(() => {
435+
// Default: no selected account — filter is a no-op.
436+
mockSelectSelectedInternalAccountByScope.mockReturnValue(
437+
jest.fn().mockReturnValue(undefined),
438+
);
439+
});
440+
406441
it('returns empty array when cardHomeData is null', () => {
407442
const state = createMockRootState({ cardHomeData: null });
408443
expect(selectCardAvailableTokens(state)).toStrictEqual([]);
@@ -422,6 +457,68 @@ describe('selectCardAvailableTokens', () => {
422457
}),
423458
);
424459
});
460+
461+
it('shows all assets when no account is selected', () => {
462+
const assets = [
463+
makeAsset({ walletAddress: WALLET_A, status: FundingAssetStatus.Active }),
464+
makeAsset({
465+
walletAddress: WALLET_B,
466+
status: FundingAssetStatus.Inactive,
467+
}),
468+
];
469+
const state = stateWithAssets(assets);
470+
expect(selectCardAvailableTokens(state)).toHaveLength(2);
471+
});
472+
473+
it('always shows Active and Limited tokens regardless of wallet', () => {
474+
const assets = [
475+
makeAsset({ walletAddress: WALLET_B, status: FundingAssetStatus.Active }),
476+
makeAsset({
477+
walletAddress: WALLET_B,
478+
status: FundingAssetStatus.Limited,
479+
}),
480+
];
481+
const state = stateWithAssets(assets, WALLET_A);
482+
expect(selectCardAvailableTokens(state)).toHaveLength(2);
483+
});
484+
485+
it('shows Inactive token only for the current wallet', () => {
486+
const assets = [
487+
makeAsset({
488+
walletAddress: WALLET_A,
489+
status: FundingAssetStatus.Inactive,
490+
}),
491+
makeAsset({
492+
walletAddress: WALLET_B,
493+
status: FundingAssetStatus.Inactive,
494+
}),
495+
];
496+
const state = stateWithAssets(assets, WALLET_A);
497+
const tokens = selectCardAvailableTokens(state);
498+
expect(tokens).toHaveLength(1);
499+
expect(tokens[0].walletAddress).toBe(WALLET_A);
500+
});
501+
502+
it('shows Inactive token with empty walletAddress for any current wallet', () => {
503+
const assets = [
504+
makeAsset({ walletAddress: '', status: FundingAssetStatus.Inactive }),
505+
];
506+
const state = stateWithAssets(assets, WALLET_A);
507+
expect(selectCardAvailableTokens(state)).toHaveLength(1);
508+
});
509+
510+
it('shows Active from walletB and Inactive placeholder for walletA simultaneously', () => {
511+
const assets = [
512+
makeAsset({ walletAddress: WALLET_B, status: FundingAssetStatus.Active }),
513+
makeAsset({
514+
walletAddress: WALLET_A,
515+
status: FundingAssetStatus.Inactive,
516+
}),
517+
];
518+
const state = stateWithAssets(assets, WALLET_A);
519+
const tokens = selectCardAvailableTokens(state);
520+
expect(tokens).toHaveLength(2);
521+
});
425522
});
426523

427524
describe('selectCardFundingTokens', () => {

0 commit comments

Comments
 (0)