Skip to content

Commit 256f96e

Browse files
authored
Merge branch 'main' into phase5d/shadow-ci-job-permissions
2 parents 62af461 + 7274b18 commit 256f96e

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)