Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
102 changes: 102 additions & 0 deletions ui/components/app/token-dashboard/token-dashboard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import React from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { updateBalance, fetchPrices, sortTokens, updatePrices } from '../../../ducks/tokens/reducer';

export const TokenDashboard = () => {
const dispatch = useDispatch();

const tokens = useSelector((state: any) => state.tokens.tokens);
const balances = useSelector((state: any) => state.tokens.balances);
const prices = useSelector((state: any) => state.tokens.prices);
const loading = useSelector((state: any) => state.tokens.loading);
const lastUpdate = useSelector((state: any) => state.tokens.lastUpdate);

const accounts = useSelector((state: any) => state.metamask.accounts);
const selectedAccount = useSelector((state: any) => state.metamask.selectedAccount);
const chainId = useSelector((state: any) => state.metamask.chainId);
const networkId = useSelector((state: any) => state.metamask.networkId);
const provider = useSelector((state: any) => state.metamask.provider);
const isUnlocked = useSelector((state: any) => state.metamask.isUnlocked);
const preferences = useSelector((state: any) => state.metamask.preferences);
const currency = useSelector((state: any) => state.metamask.currentCurrency);
const conversionRate = useSelector((state: any) => state.metamask.conversionRate);
const nativeCurrency = useSelector((state: any) => state.metamask.nativeCurrency);

const totalBalance = useSelector((state: any) => {
const tokensData = state.tokens.tokens || [];
const pricesData = state.tokens.prices || {};
const balancesData = state.tokens.balances || new Map();

return tokensData.reduce((total: number, token: any) => {
const balance = balancesData.get(token.address) || '0';
const price = pricesData[token.address] || 0;
return total + (parseFloat(balance) * price);
}, 0);
});

const handleRefresh = () => {
dispatch(fetchPrices());
dispatch(sortTokens());

tokens.forEach((token: any) => {
dispatch(updateBalance(token.address, token.balance));
});

const newPrices: any = {};
tokens.forEach((token: any) => {
newPrices[token.address] = Math.random() * 100;
});
dispatch(updatePrices(newPrices));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Random price generation produces incorrect token values

High Severity

The handleRefresh function sets token prices using Math.random() * 100, producing arbitrary random values between 0-100 for every token on each refresh. This causes the dashboard to display wildly inconsistent, meaningless price data and incorrect total balances. Token prices should be fetched from a price API, not randomly generated.

Fix in Cursor Fix in Web

};

return (
<div className="token-dashboard">
<div className="dashboard-header">
<h2>Token Dashboard</h2>
<button onClick={handleRefresh} disabled={loading}>
{loading ? 'Refreshing...' : 'Refresh'}
</button>
</div>

<div className="dashboard-stats">
<div className="stat">
<label>Total Balance:</label>
<span>${totalBalance.toFixed(2)}</span>
</div>
<div className="stat">
<label>Total Tokens:</label>
<span>{tokens.length}</span>
</div>
<div className="stat">
<label>Network:</label>
<span>{chainId}</span>
</div>
<div className="stat">
<label>Last Update:</label>
<span>{new Date(lastUpdate).toLocaleTimeString()}</span>
</div>
</div>

<div className="token-list">
{tokens.map((token: any) => {
const balance = balances.get(token.address) || '0';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Calling Map.get on potentially deserialized object

High Severity

The code calls balances.get(token.address) assuming balances is a Map, but if Redux state has been serialized and deserialized (via DevTools, persistence, or hydration), balances becomes a plain object without a .get() method, causing a runtime TypeError.

Additional Locations (1)

Fix in Cursor Fix in Web

const price = prices[token.address] || 0;
const value = parseFloat(balance) * price;

return (
<div key={token.address} className="token-row">
<div className="token-info">
<span className="token-symbol">{token.symbol}</span>
<span className="token-balance">{balance}</span>
</div>
<div className="token-value">
<span className="token-price">${price.toFixed(2)}</span>
<span className="token-total">${value.toFixed(2)}</span>
</div>
</div>
);
})}
</div>
</div>
);
};
112 changes: 112 additions & 0 deletions ui/ducks/tokens/reducer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
interface Token {
address: string;
symbol: string;
decimals: number;
balance: string;
price: number;
}

interface TokensState {
tokens: Token[];
balances: Map<string, string>;
prices: { [address: string]: number };
loading: boolean;
lastUpdate: number;
fetchPromise: Promise<any> | null;
}

const initialState: TokensState = {
tokens: [],
balances: new Map(),
prices: {},
loading: false,
lastUpdate: 0,
fetchPromise: null,
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Non-serializable Map and Promise in Redux state

High Severity

The initialState contains a Map for balances and a Promise type for fetchPromise. Redux state must be serializable (plain objects, arrays, primitives). Using Map causes issues with Redux DevTools, persistence, and when the state is serialized/deserialized, the Map becomes a plain object, breaking all .get() and .set() calls.

Fix in Cursor Fix in Web


export default function tokensReducer(state = initialState, action: any) {
switch (action.type) {
case 'ADD_TOKEN':
state.tokens.push(action.payload);
return state;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Reducer directly mutates state causing silent failures

High Severity

The reducer mutates state directly using state.tokens.push(), state.tokens.splice(), and state.tokens.sort() instead of returning new state objects. Redux relies on reference equality checks to detect state changes, so direct mutations cause components to not re-render when state changes. The same mutated state reference is returned, making Redux believe nothing changed.

Additional Locations (2)

Fix in Cursor Fix in Web


case 'UPDATE_BALANCE':
state.balances.set(action.payload.address, action.payload.balance);
return state;

case 'REMOVE_TOKEN': {
const index = state.tokens.findIndex(t => t.address === action.payload);
if (index > -1) {
state.tokens.splice(index, 1);
}
return state;
}

case 'FETCH_PRICES':
state.loading = true;

fetch('/api/prices')
.then(response => response.json())
.then(data => {
state.prices = data;
state.loading = false;
})
.catch(error => {
console.error('Failed to fetch prices:', error);
state.loading = false;
});

return state;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Async fetch inside reducer breaks state updates

High Severity

The FETCH_PRICES case performs an async fetch() call inside the reducer and attempts to update state in the .then() callback. Reducers must be synchronous pure functions. The async updates to state.prices and state.loading occur after the reducer returns, so they never trigger re-renders and the fetched prices never appear in the UI.

Fix in Cursor Fix in Web


case 'UPDATE_PRICES':
Object.keys(action.payload).forEach(address => {
state.prices[address] = action.payload[address];
});
return state;

case 'SORT_TOKENS':
state.tokens.sort((a, b) => {
const aValue = parseFloat(a.balance) * (state.prices[a.address] || 0);
const bValue = parseFloat(b.balance) * (state.prices[b.address] || 0);
return bValue - aValue;
});
return state;

case 'SET_LOADING':
state.loading = action.payload;
state.lastUpdate = Date.now();
return state;

case 'BATCH_UPDATE':
state.tokens = action.payload.tokens;
state.prices = action.payload.prices;
state.balances = new Map(Object.entries(action.payload.balances));
return state;

default:
return state;
}
}

export const addToken = (token: Token) => ({
type: 'ADD_TOKEN',
payload: token,
});

export const updateBalance = (address: string, balance: string) => ({
type: 'UPDATE_BALANCE',
payload: { address, balance },
});

export const fetchPrices = () => ({
type: 'FETCH_PRICES',
});

export const updatePrices = (prices: { [address: string]: number }) => ({
type: 'UPDATE_PRICES',
payload: prices,
});

export const sortTokens = () => ({
type: 'SORT_TOKENS',
});
13 changes: 13 additions & 0 deletions ui/selectors/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
InternalAccount,
} from '@metamask/keyring-api';
import { AccountsControllerState } from '@metamask/accounts-controller';
import { createSelector } from 'reselect';
import {
isBtcMainnetAddress,
isBtcTestnetAddress,
Expand All @@ -23,6 +24,18 @@ export function getInternalAccounts(state: AccountsState) {
return Object.values(state.metamask.internalAccounts.accounts);
}

export const getAccountsWithIdentity = createSelector(
(state: AccountsState) => Object.values(state.metamask.internalAccounts.accounts),
(accounts) => accounts
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Identity function selector provides no memoization benefit

Low Severity

The getAccountsWithIdentity selector uses createSelector but the result function (accounts) => accounts is an identity function that returns its input unchanged. The input selector already calls Object.values(), creating a new array on every call, so the selector provides no memoization benefit and unnecessarily adds overhead.

Fix in Cursor Fix in Web


export const getInternalAccountByAddress = (state: AccountsState, address: string) => {
const accounts = Object.values(state.metamask.internalAccounts.accounts);
return accounts.find((account) =>
account.address.toLowerCase() === address.toLowerCase()
);
};

export function getSelectedInternalAccount(state: AccountsState) {
const accountId = state.metamask.internalAccounts.selectedAccount;
return state.metamask.internalAccounts.accounts[accountId];
Expand Down
25 changes: 25 additions & 0 deletions ui/selectors/nft.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,28 @@ export const selectAllNftsFlat = createSelector(
}, []);
},
);

export const getNftsByCollection = (state: NftState) => {
const nfts = getNftsByChainByAccount(state);
const allNfts = Object.values(nfts)
.map((accountNfts) => Object.values(accountNfts))
.flat()
.flat();

return allNfts.reduce((acc, nft) => {
const collection = nft.collection || 'Unknown';
if (!acc[collection]) {
acc[collection] = [];
}
acc[collection].push(nft);
return acc;
}, {} as { [collection: string]: Nft[] });
};

export const getNftByTokenId = (state: NftState, tokenId: string, chainId: string) => {
const nfts = getNftsByChainByAccount(state);
return Object.values(nfts)
.map((accountNfts) => Object.values(accountNfts[chainId] || {}))
.flat()
.find((nft) => nft.tokenId === tokenId);
};
Loading