mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-22 05:37:56 +00:00
Add Nova Wallet style token list with real prices
- Fix showAlert crash (recursive call -> Alert.alert) - Add TokenService for dynamic token fetching - Known tokens (HEZ, PEZ, USDT, DOT, BTC, ETH) always shown - CoinGecko prices with fallback (HEZ=DOT/4, PEZ=DOT/10) - Use PNG logos for HEZ/PEZ instead of SVG components - Token search uses allTokens with proper filtering - 30s auto-refresh for price updates
This commit is contained in:
@@ -26,8 +26,9 @@ import { KurdistanColors } from '../theme/colors';
|
||||
import { usePezkuwi, NetworkType, NETWORKS } from '../contexts/PezkuwiContext';
|
||||
import { AddTokenModal } from '../components/wallet/AddTokenModal';
|
||||
import { QRScannerModal } from '../components/wallet/QRScannerModal';
|
||||
import { HezTokenLogo, PezTokenLogo } from '../components/icons';
|
||||
// Token logo PNG files are used directly instead of SVG components
|
||||
import { decodeAddress, checkAddress, encodeAddress } from '@pezkuwi/util-crypto';
|
||||
import { fetchAllTokens, TokenInfo, subscribeToTokenBalances, KNOWN_TOKENS, TOKEN_LOGOS } from '../services/TokenService';
|
||||
|
||||
// Secure storage helper - same as in PezkuwiContext
|
||||
const secureStorage = {
|
||||
@@ -55,7 +56,7 @@ const showAlert = (title: string, message: string, buttons?: Array<{text: string
|
||||
if (buttons?.[0]?.onPress) buttons[0].onPress();
|
||||
}
|
||||
} else {
|
||||
showAlert(title, message, buttons as any);
|
||||
Alert.alert(title, message, buttons as any);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -160,6 +161,27 @@ const WalletScreen: React.FC = () => {
|
||||
const [saveAddressModalVisible, setSaveAddressModalVisible] = useState(false);
|
||||
const [newAddressName, setNewAddressName] = useState('');
|
||||
|
||||
// All tokens from blockchain (Nova Wallet style)
|
||||
// Initialize with known tokens so list is never empty
|
||||
const [allTokens, setAllTokens] = useState<TokenInfo[]>(() =>
|
||||
KNOWN_TOKENS.map(kt => ({
|
||||
assetId: kt.assetId,
|
||||
symbol: kt.symbol,
|
||||
name: kt.name,
|
||||
decimals: kt.decimals,
|
||||
balance: '0.00',
|
||||
balanceRaw: 0n,
|
||||
usdValue: '$0.00',
|
||||
priceUsd: 0,
|
||||
change24h: 0,
|
||||
logo: TOKEN_LOGOS[kt.symbol] || null,
|
||||
isNative: kt.isNative,
|
||||
isFrozen: false,
|
||||
}))
|
||||
);
|
||||
const [isLoadingTokens, setIsLoadingTokens] = useState(false);
|
||||
|
||||
// Legacy tokens array for backward compatibility
|
||||
const tokens: Token[] = [
|
||||
{
|
||||
symbol: 'HEZ',
|
||||
@@ -295,6 +317,31 @@ const WalletScreen: React.FC = () => {
|
||||
};
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
// Fetch all tokens from blockchain (Nova Wallet style)
|
||||
useEffect(() => {
|
||||
if (!api || !isApiReady || !selectedAccount) return;
|
||||
|
||||
const loadAllTokens = async () => {
|
||||
setIsLoadingTokens(true);
|
||||
try {
|
||||
const tokens = await fetchAllTokens(api, selectedAccount.address);
|
||||
setAllTokens(tokens);
|
||||
console.log('[Wallet] Loaded', tokens.length, 'tokens from blockchain');
|
||||
} catch (error) {
|
||||
console.error('[Wallet] Failed to load tokens:', error);
|
||||
} finally {
|
||||
setIsLoadingTokens(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAllTokens();
|
||||
|
||||
// Refresh every 30 seconds for price updates
|
||||
const interval = setInterval(loadAllTokens, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [api, isApiReady, selectedAccount]);
|
||||
|
||||
const handleTokenPress = (token: Token) => {
|
||||
if (!token.isLive) return;
|
||||
setSelectedToken(token);
|
||||
@@ -705,7 +752,7 @@ const WalletScreen: React.FC = () => {
|
||||
{/* HEZ Card */}
|
||||
<TouchableOpacity style={styles.mainTokenCard} onPress={() => handleTokenPress(tokens[0])}>
|
||||
<View style={styles.mainTokenLogoContainer}>
|
||||
<HezTokenLogo size={56} />
|
||||
<Image source={hezLogo} style={styles.mainTokenLogo} resizeMode="contain" />
|
||||
</View>
|
||||
<Text style={styles.mainTokenSymbol}>HEZ</Text>
|
||||
<Text style={styles.mainTokenBalance}>{balances.HEZ}</Text>
|
||||
@@ -715,7 +762,7 @@ const WalletScreen: React.FC = () => {
|
||||
{/* PEZ Card */}
|
||||
<TouchableOpacity style={styles.mainTokenCard} onPress={() => handleTokenPress(tokens[1])}>
|
||||
<View style={styles.mainTokenLogoContainer}>
|
||||
<PezTokenLogo size={56} />
|
||||
<Image source={pezLogo} style={styles.mainTokenLogo} resizeMode="contain" />
|
||||
</View>
|
||||
<Text style={styles.mainTokenSymbol}>PEZ</Text>
|
||||
<Text style={styles.mainTokenBalance}>{balances.PEZ}</Text>
|
||||
@@ -746,7 +793,7 @@ const WalletScreen: React.FC = () => {
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{/* Tokens List */}
|
||||
{/* Tokens List - Nova Wallet Style */}
|
||||
<View style={styles.tokensSection}>
|
||||
<View style={styles.tokensSectionHeader}>
|
||||
<Text style={styles.tokensTitle}>Tokens</Text>
|
||||
@@ -763,31 +810,85 @@ const WalletScreen: React.FC = () => {
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* USDT */}
|
||||
<TouchableOpacity style={styles.tokenListItem}>
|
||||
<Image source={usdtLogo} style={styles.tokenListLogo} resizeMode="contain" />
|
||||
<View style={styles.tokenListInfo}>
|
||||
<Text style={styles.tokenListSymbol}>USDT</Text>
|
||||
<Text style={styles.tokenListNetwork}>PEZ Network</Text>
|
||||
{/* Loading indicator */}
|
||||
{isLoadingTokens && allTokens.length === 0 && (
|
||||
<View style={styles.loadingTokens}>
|
||||
<ActivityIndicator size="small" color={KurdistanColors.kesk} />
|
||||
<Text style={styles.loadingTokensText}>Loading tokens...</Text>
|
||||
</View>
|
||||
<View style={styles.tokenListBalance}>
|
||||
<Text style={styles.tokenListAmount}>{balances.USDT}</Text>
|
||||
<Text style={styles.tokenListUsdValue}>$0.00</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* DOT */}
|
||||
<TouchableOpacity style={styles.tokenListItem}>
|
||||
<Image source={dotLogo} style={styles.tokenListLogo} resizeMode="contain" />
|
||||
<View style={styles.tokenListInfo}>
|
||||
<Text style={styles.tokenListSymbol}>DOT</Text>
|
||||
<Text style={styles.tokenListNetwork}>Polkadot</Text>
|
||||
{/* Dynamic Token List */}
|
||||
{allTokens
|
||||
.filter(t => !hiddenTokens.includes(t.symbol))
|
||||
.map((token, index) => {
|
||||
|
||||
const changeColor = token.change24h >= 0 ? '#22C55E' : '#EF4444';
|
||||
const changePrefix = token.change24h >= 0 ? '+' : '';
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
key={token.assetId ?? token.symbol}
|
||||
style={styles.tokenListItem}
|
||||
onPress={() => {
|
||||
// Convert TokenInfo to Token for send modal
|
||||
setSelectedToken({
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
balance: token.balance,
|
||||
value: token.usdValue,
|
||||
change: `${changePrefix}${token.change24h.toFixed(2)}%`,
|
||||
logo: token.logo || usdtLogo,
|
||||
assetId: token.assetId ?? undefined,
|
||||
isLive: true,
|
||||
});
|
||||
setSendModalVisible(true);
|
||||
}}
|
||||
>
|
||||
{/* Token Logo */}
|
||||
{token.logo ? (
|
||||
<Image source={token.logo} style={styles.tokenListLogo} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={[styles.tokenListLogo, styles.tokenPlaceholderLogo]}>
|
||||
<Text style={styles.tokenPlaceholderText}>{token.symbol.slice(0, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Token Info */}
|
||||
<View style={styles.tokenListInfo}>
|
||||
<Text style={styles.tokenListSymbol}>{token.symbol}</Text>
|
||||
<Text style={styles.tokenListNetwork}>{token.name}</Text>
|
||||
</View>
|
||||
|
||||
{/* Balance & Price */}
|
||||
<View style={styles.tokenListBalance}>
|
||||
<Text style={styles.tokenListAmount}>{token.balance}</Text>
|
||||
<View style={styles.tokenPriceRow}>
|
||||
<Text style={styles.tokenListUsdValue}>{token.usdValue}</Text>
|
||||
{token.change24h !== 0 && (
|
||||
<Text style={[styles.tokenChange, { color: changeColor }]}>
|
||||
{changePrefix}{token.change24h.toFixed(1)}%
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoadingTokens && allTokens.length === 0 && (
|
||||
<View style={styles.emptyTokens}>
|
||||
<Text style={styles.emptyTokensIcon}>🪙</Text>
|
||||
<Text style={styles.emptyTokensText}>No additional tokens found</Text>
|
||||
<TouchableOpacity
|
||||
style={styles.addTokenButton}
|
||||
onPress={() => setAddTokenModalVisible(true)}
|
||||
>
|
||||
<Text style={styles.addTokenButtonText}>+ Add Token</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.tokenListBalance}>
|
||||
<Text style={styles.tokenListAmount}>0.00</Text>
|
||||
<Text style={styles.tokenListUsdValue}>$0.00</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View style={{height: 100}} />
|
||||
@@ -1173,7 +1274,7 @@ const WalletScreen: React.FC = () => {
|
||||
autoFocus
|
||||
/>
|
||||
<ScrollView style={styles.tokenSearchResults}>
|
||||
{tokens
|
||||
{allTokens
|
||||
.filter(t =>
|
||||
!hiddenTokens.includes(t.symbol) &&
|
||||
(t.symbol.toLowerCase().includes(tokenSearchQuery.toLowerCase()) ||
|
||||
@@ -1181,23 +1282,43 @@ const WalletScreen: React.FC = () => {
|
||||
)
|
||||
.map((token) => (
|
||||
<TouchableOpacity
|
||||
key={token.symbol}
|
||||
key={token.assetId ?? token.symbol}
|
||||
style={styles.tokenSearchItem}
|
||||
onPress={() => {
|
||||
setTokenSearchVisible(false);
|
||||
setTokenSearchQuery('');
|
||||
handleTokenPress(token);
|
||||
// Convert TokenInfo to Token format for send modal
|
||||
setSelectedToken({
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
balance: token.balance,
|
||||
value: token.usdValue,
|
||||
change: `${token.change24h >= 0 ? '+' : ''}${token.change24h.toFixed(2)}%`,
|
||||
logo: token.logo || usdtLogo,
|
||||
assetId: token.assetId ?? undefined,
|
||||
isLive: true,
|
||||
});
|
||||
setSendModalVisible(true);
|
||||
}}
|
||||
>
|
||||
<Image source={token.logo} style={styles.tokenSearchLogo} resizeMode="contain" />
|
||||
{token.logo ? (
|
||||
<Image source={token.logo} style={styles.tokenSearchLogo} resizeMode="contain" />
|
||||
) : (
|
||||
<View style={[styles.tokenSearchLogo, styles.tokenPlaceholderLogo]}>
|
||||
<Text style={styles.tokenPlaceholderText}>{token.symbol.slice(0, 2)}</Text>
|
||||
</View>
|
||||
)}
|
||||
<View style={{flex: 1}}>
|
||||
<Text style={styles.tokenSearchSymbol}>{token.symbol}</Text>
|
||||
<Text style={styles.tokenSearchName}>{token.name}</Text>
|
||||
</View>
|
||||
<Text style={styles.tokenSearchBalance}>{balances[token.symbol] || '0.00'}</Text>
|
||||
<View style={{alignItems: 'flex-end'}}>
|
||||
<Text style={styles.tokenSearchBalance}>{token.balance}</Text>
|
||||
<Text style={styles.tokenSearchUsd}>{token.usdValue}</Text>
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
{tokens.filter(t =>
|
||||
{allTokens.filter(t =>
|
||||
!hiddenTokens.includes(t.symbol) &&
|
||||
(t.symbol.toLowerCase().includes(tokenSearchQuery.toLowerCase()) ||
|
||||
t.name.toLowerCase().includes(tokenSearchQuery.toLowerCase()))
|
||||
@@ -1366,6 +1487,11 @@ const styles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
mainTokenLogo: {
|
||||
width: 56,
|
||||
height: 56,
|
||||
borderRadius: 28,
|
||||
},
|
||||
mainTokenSymbol: {
|
||||
fontSize: 18,
|
||||
fontWeight: 'bold',
|
||||
@@ -1480,6 +1606,62 @@ const styles = StyleSheet.create({
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
},
|
||||
tokenPriceRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
tokenChange: {
|
||||
fontSize: 11,
|
||||
fontWeight: '600',
|
||||
},
|
||||
tokenPlaceholderLogo: {
|
||||
backgroundColor: '#E5E7EB',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
borderRadius: 22,
|
||||
},
|
||||
tokenPlaceholderText: {
|
||||
fontSize: 14,
|
||||
fontWeight: 'bold',
|
||||
color: '#6B7280',
|
||||
},
|
||||
loadingTokens: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 20,
|
||||
gap: 10,
|
||||
},
|
||||
loadingTokensText: {
|
||||
fontSize: 14,
|
||||
color: '#666',
|
||||
},
|
||||
emptyTokens: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: 32,
|
||||
},
|
||||
emptyTokensIcon: {
|
||||
fontSize: 48,
|
||||
marginBottom: 12,
|
||||
},
|
||||
emptyTokensText: {
|
||||
fontSize: 14,
|
||||
color: '#999',
|
||||
marginBottom: 16,
|
||||
},
|
||||
addTokenButton: {
|
||||
backgroundColor: KurdistanColors.kesk,
|
||||
paddingHorizontal: 20,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 20,
|
||||
},
|
||||
addTokenButtonText: {
|
||||
color: '#FFFFFF',
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
},
|
||||
|
||||
// Modal Styles
|
||||
modalOverlay: {
|
||||
@@ -1983,6 +2165,11 @@ const styles = StyleSheet.create({
|
||||
fontWeight: '600',
|
||||
color: KurdistanColors.kesk,
|
||||
},
|
||||
tokenSearchUsd: {
|
||||
fontSize: 12,
|
||||
color: '#888',
|
||||
marginTop: 2,
|
||||
},
|
||||
noTokensFound: {
|
||||
textAlign: 'center',
|
||||
color: '#999',
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
import type { ApiPromise } from '@pezkuwi/api';
|
||||
import { decodeAddress } from '@pezkuwi/util-crypto';
|
||||
|
||||
/**
|
||||
* Token metadata and balance information
|
||||
*/
|
||||
export interface TokenInfo {
|
||||
assetId: number | null; // null for native token (HEZ)
|
||||
symbol: string;
|
||||
name: string;
|
||||
decimals: number;
|
||||
balance: string;
|
||||
balanceRaw: bigint;
|
||||
usdValue: string;
|
||||
priceUsd: number;
|
||||
change24h: number;
|
||||
logo: string | null;
|
||||
isNative: boolean;
|
||||
isFrozen: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Price data from external API
|
||||
*/
|
||||
interface PriceData {
|
||||
[symbol: string]: {
|
||||
usd: number;
|
||||
usd_24h_change: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Known token logos mapping
|
||||
export const TOKEN_LOGOS: { [symbol: string]: any } = {
|
||||
HEZ: require('../../../shared/images/hez_token_512.png'),
|
||||
PEZ: require('../../../shared/images/pez_token_512.png'),
|
||||
USDT: require('../../../shared/images/USDT(hez)logo.png'),
|
||||
DOT: require('../../../shared/images/dot.png'),
|
||||
BTC: require('../../../shared/images/bitcoin.png'),
|
||||
ETH: require('../../../shared/images/etherium.png'),
|
||||
BNB: require('../../../shared/images/BNB_logo.png'),
|
||||
ADA: require('../../../shared/images/ADAlogo.png'),
|
||||
};
|
||||
|
||||
// Predefined known tokens on PezkuwiChain
|
||||
// These will always be shown even if chain query fails
|
||||
export const KNOWN_TOKENS: Array<{
|
||||
assetId: number | null;
|
||||
symbol: string;
|
||||
name: string;
|
||||
decimals: number;
|
||||
isNative: boolean;
|
||||
}> = [
|
||||
{ assetId: null, symbol: 'HEZ', name: 'Pezkuwi Coin', decimals: 12, isNative: true },
|
||||
{ assetId: 1, symbol: 'PEZ', name: 'Pezkuwi Token', decimals: 12, isNative: false },
|
||||
{ assetId: 1000, symbol: 'USDT', name: 'Tether USD', decimals: 6, isNative: false },
|
||||
{ assetId: 1001, symbol: 'DOT', name: 'Polkadot (Bridged)', decimals: 10, isNative: false },
|
||||
{ assetId: 1002, symbol: 'BTC', name: 'Bitcoin (Bridged)', decimals: 8, isNative: false },
|
||||
{ assetId: 1003, symbol: 'ETH', name: 'Ethereum (Bridged)', decimals: 18, isNative: false },
|
||||
];
|
||||
|
||||
// CoinGecko ID mapping for price fetching
|
||||
const COINGECKO_IDS: { [symbol: string]: string } = {
|
||||
DOT: 'polkadot',
|
||||
BTC: 'bitcoin',
|
||||
ETH: 'ethereum',
|
||||
BNB: 'binancecoin',
|
||||
ADA: 'cardano',
|
||||
USDT: 'tether',
|
||||
HEZ: 'pezkuwi', // Try CoinGecko first, fallback to DOT/8
|
||||
PEZ: 'pezkuwi-token', // Try CoinGecko first, fallback to DOT/8
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch current prices from CoinGecko API
|
||||
* HEZ/PEZ fallback: DOT price / 8 (if not on CoinGecko)
|
||||
*/
|
||||
export async function fetchTokenPrices(symbols: string[]): Promise<PriceData> {
|
||||
const prices: PriceData = {};
|
||||
|
||||
// Always fetch DOT price for HEZ/PEZ fallback calculation
|
||||
const symbolsToFetch = [...new Set([...symbols, 'DOT'])];
|
||||
|
||||
// Get CoinGecko IDs
|
||||
const geckoIds = symbolsToFetch
|
||||
.filter(s => COINGECKO_IDS[s])
|
||||
.map(s => COINGECKO_IDS[s]);
|
||||
|
||||
if (geckoIds.length > 0) {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://api.coingecko.com/api/v3/simple/price?ids=${geckoIds.join(',')}&vs_currencies=usd&include_24hr_change=true`,
|
||||
{ headers: { 'Accept': 'application/json' } }
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Map back to our symbols
|
||||
for (const symbol of symbolsToFetch) {
|
||||
const geckoId = COINGECKO_IDS[symbol];
|
||||
if (geckoId && data[geckoId]) {
|
||||
prices[symbol] = {
|
||||
usd: data[geckoId].usd || 0,
|
||||
usd_24h_change: data[geckoId].usd_24h_change || 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('[TokenService] CoinGecko API error:', response.status);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[TokenService] Failed to fetch prices:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for HEZ: DOT price / 4
|
||||
if (!prices['HEZ'] && prices['DOT']) {
|
||||
const hezPrice = parseFloat((prices['DOT'].usd / 4).toFixed(2));
|
||||
prices['HEZ'] = {
|
||||
usd: hezPrice,
|
||||
usd_24h_change: prices['DOT'].usd_24h_change,
|
||||
};
|
||||
console.log(`[TokenService] HEZ price calculated from DOT/4: $${hezPrice}`);
|
||||
}
|
||||
|
||||
// Fallback for PEZ: DOT price / 10
|
||||
if (!prices['PEZ'] && prices['DOT']) {
|
||||
const pezPrice = parseFloat((prices['DOT'].usd / 10).toFixed(2));
|
||||
prices['PEZ'] = {
|
||||
usd: pezPrice,
|
||||
usd_24h_change: prices['DOT'].usd_24h_change,
|
||||
};
|
||||
console.log(`[TokenService] PEZ price calculated from DOT/10: $${pezPrice}`);
|
||||
}
|
||||
|
||||
return prices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format balance with proper decimals
|
||||
*/
|
||||
function formatBalance(rawBalance: bigint, decimals: number): string {
|
||||
const divisor = BigInt(10 ** decimals);
|
||||
const intPart = rawBalance / divisor;
|
||||
const fracPart = rawBalance % divisor;
|
||||
|
||||
if (fracPart === 0n) {
|
||||
return intPart.toString();
|
||||
}
|
||||
|
||||
const fracStr = fracPart.toString().padStart(decimals, '0');
|
||||
// Trim trailing zeros but keep at least 2 decimals
|
||||
const trimmed = fracStr.replace(/0+$/, '').slice(0, 4);
|
||||
|
||||
if (trimmed.length === 0) {
|
||||
return intPart.toString();
|
||||
}
|
||||
|
||||
return `${intPart}.${trimmed}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all tokens and their balances for an account
|
||||
* Uses KNOWN_TOKENS as base, then fetches balances from chain
|
||||
*/
|
||||
export async function fetchAllTokens(
|
||||
api: ApiPromise,
|
||||
accountAddress: string
|
||||
): Promise<TokenInfo[]> {
|
||||
const tokens: TokenInfo[] = [];
|
||||
const addedAssetIds = new Set<number | null>();
|
||||
|
||||
try {
|
||||
// Decode address for queries
|
||||
let accountId: Uint8Array;
|
||||
try {
|
||||
accountId = decodeAddress(accountAddress);
|
||||
} catch (e) {
|
||||
console.warn('[TokenService] Failed to decode address:', e);
|
||||
// Return known tokens with zero balances
|
||||
return KNOWN_TOKENS.map(kt => ({
|
||||
assetId: kt.assetId,
|
||||
symbol: kt.symbol,
|
||||
name: kt.name,
|
||||
decimals: kt.decimals,
|
||||
balance: '0.00',
|
||||
balanceRaw: 0n,
|
||||
usdValue: '$0.00',
|
||||
priceUsd: 0,
|
||||
change24h: 0,
|
||||
logo: TOKEN_LOGOS[kt.symbol] || null,
|
||||
isNative: kt.isNative,
|
||||
isFrozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
// 1. Add all known tokens first
|
||||
for (const knownToken of KNOWN_TOKENS) {
|
||||
let balanceRaw = 0n;
|
||||
let isFrozen = false;
|
||||
|
||||
try {
|
||||
if (knownToken.isNative) {
|
||||
// Native token (HEZ) - query system account
|
||||
const accountInfo = await api.query.system.account(accountId) as any;
|
||||
balanceRaw = BigInt(accountInfo.data.free.toString());
|
||||
} else if (api.query.assets?.account && knownToken.assetId !== null) {
|
||||
// Asset token - query assets pallet
|
||||
const assetAccount = await api.query.assets.account(knownToken.assetId, accountId) as any;
|
||||
if (assetAccount && !assetAccount.isEmpty && assetAccount.isSome) {
|
||||
const accountData = assetAccount.unwrap();
|
||||
balanceRaw = BigInt(accountData.balance.toString());
|
||||
isFrozen = accountData.status?.isFrozen || false;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[TokenService] Could not fetch balance for ${knownToken.symbol}:`, e);
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
assetId: knownToken.assetId,
|
||||
symbol: knownToken.symbol,
|
||||
name: knownToken.name,
|
||||
decimals: knownToken.decimals,
|
||||
balance: formatBalance(balanceRaw, knownToken.decimals),
|
||||
balanceRaw,
|
||||
usdValue: '$0.00',
|
||||
priceUsd: 0,
|
||||
change24h: 0,
|
||||
logo: TOKEN_LOGOS[knownToken.symbol] || null,
|
||||
isNative: knownToken.isNative,
|
||||
isFrozen,
|
||||
});
|
||||
|
||||
addedAssetIds.add(knownToken.assetId);
|
||||
}
|
||||
|
||||
// 2. Fetch any additional registered assets from chain
|
||||
if (api.query.assets?.metadata) {
|
||||
try {
|
||||
const assetEntries = await api.query.assets.metadata.entries();
|
||||
|
||||
for (const [key, value] of assetEntries) {
|
||||
const assetId = (key.args[0] as any).toNumber();
|
||||
|
||||
// Skip if already added from known tokens
|
||||
if (addedAssetIds.has(assetId)) continue;
|
||||
|
||||
const metadata = value as any;
|
||||
if (metadata.isEmpty) continue;
|
||||
|
||||
const symbol = metadata.symbol.toHuman();
|
||||
const name = metadata.name.toHuman();
|
||||
const decimals = metadata.decimals.toNumber();
|
||||
|
||||
// Fetch balance for this asset
|
||||
let balanceRaw = 0n;
|
||||
let isFrozen = false;
|
||||
|
||||
try {
|
||||
const assetAccount = await api.query.assets.account(assetId, accountId) as any;
|
||||
if (assetAccount && !assetAccount.isEmpty && assetAccount.isSome) {
|
||||
const accountData = assetAccount.unwrap();
|
||||
balanceRaw = BigInt(accountData.balance.toString());
|
||||
isFrozen = accountData.status?.isFrozen || false;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log(`[TokenService] Failed to fetch balance for asset ${assetId}`);
|
||||
}
|
||||
|
||||
tokens.push({
|
||||
assetId,
|
||||
symbol,
|
||||
name,
|
||||
decimals,
|
||||
balance: formatBalance(balanceRaw, decimals),
|
||||
balanceRaw,
|
||||
usdValue: '$0.00',
|
||||
priceUsd: 0,
|
||||
change24h: 0,
|
||||
logo: TOKEN_LOGOS[symbol] || null,
|
||||
isNative: false,
|
||||
isFrozen,
|
||||
});
|
||||
|
||||
addedAssetIds.add(assetId);
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[TokenService] Assets pallet query failed, using known tokens only');
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch prices and update USD values
|
||||
const symbols = tokens.map(t => t.symbol);
|
||||
const prices = await fetchTokenPrices(symbols);
|
||||
|
||||
for (const token of tokens) {
|
||||
if (prices[token.symbol]) {
|
||||
token.priceUsd = prices[token.symbol].usd;
|
||||
token.change24h = prices[token.symbol].usd_24h_change;
|
||||
|
||||
const balanceNum = parseFloat(token.balance) || 0;
|
||||
const usdValue = balanceNum * token.priceUsd;
|
||||
token.usdValue = usdValue > 0 ? `$${usdValue.toFixed(2)}` : '$0.00';
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: native first, then PEZ, then by USD value descending
|
||||
tokens.sort((a, b) => {
|
||||
if (a.isNative && !b.isNative) return -1;
|
||||
if (!a.isNative && b.isNative) return 1;
|
||||
if (a.symbol === 'PEZ' && b.symbol !== 'PEZ') return -1;
|
||||
if (a.symbol !== 'PEZ' && b.symbol === 'PEZ') return 1;
|
||||
const aValue = parseFloat(a.usdValue.replace('$', '')) || 0;
|
||||
const bValue = parseFloat(b.usdValue.replace('$', '')) || 0;
|
||||
return bValue - aValue;
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[TokenService] Error fetching tokens:', error);
|
||||
// Return known tokens with zero balances on error
|
||||
return KNOWN_TOKENS.map(kt => ({
|
||||
assetId: kt.assetId,
|
||||
symbol: kt.symbol,
|
||||
name: kt.name,
|
||||
decimals: kt.decimals,
|
||||
balance: '0.00',
|
||||
balanceRaw: 0n,
|
||||
usdValue: '$0.00',
|
||||
priceUsd: 0,
|
||||
change24h: 0,
|
||||
logo: TOKEN_LOGOS[kt.symbol] || null,
|
||||
isNative: kt.isNative,
|
||||
isFrozen: false,
|
||||
}));
|
||||
}
|
||||
|
||||
return tokens;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to balance changes for all tokens
|
||||
*/
|
||||
export async function subscribeToTokenBalances(
|
||||
api: ApiPromise,
|
||||
accountAddress: string,
|
||||
onUpdate: (tokens: TokenInfo[]) => void
|
||||
): Promise<() => void> {
|
||||
const unsubscribes: (() => void)[] = [];
|
||||
|
||||
try {
|
||||
const accountId = decodeAddress(accountAddress);
|
||||
|
||||
// Subscribe to native balance
|
||||
const unsubNative = await api.query.system.account(accountId, async () => {
|
||||
const tokens = await fetchAllTokens(api, accountAddress);
|
||||
onUpdate(tokens);
|
||||
}) as unknown as () => void;
|
||||
|
||||
unsubscribes.push(unsubNative);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[TokenService] Subscription error:', error);
|
||||
}
|
||||
|
||||
return () => {
|
||||
unsubscribes.forEach(unsub => unsub());
|
||||
};
|
||||
}
|
||||
|
||||
export function getTokenLogo(symbol: string): any {
|
||||
return TOKEN_LOGOS[symbol] || null;
|
||||
}
|
||||
Reference in New Issue
Block a user