mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-04-27 19:38:00 +00:00
386 lines
12 KiB
TypeScript
386 lines
12 KiB
TypeScript
import type { ApiPromise } from '@pezkuwi/api';
|
|
import { decodeAddress } from '@pezkuwi/util-crypto';
|
|
import { ImageSourcePropType } from 'react-native';
|
|
|
|
// Import token logos
|
|
import hezLogo from '../../../shared/images/hez_token_512.png';
|
|
import pezLogo from '../../../shared/images/pez_token_512.png';
|
|
import usdtLogo from '../../../shared/images/USDT(hez)logo.png';
|
|
import dotLogo from '../../../shared/images/dot.png';
|
|
import btcLogo from '../../../shared/images/bitcoin.png';
|
|
import ethLogo from '../../../shared/images/etherium.png';
|
|
import bnbLogo from '../../../shared/images/BNB_logo.png';
|
|
import adaLogo from '../../../shared/images/ADAlogo.png';
|
|
|
|
/**
|
|
* 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: ImageSourcePropType | 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]: ImageSourcePropType } = {
|
|
HEZ: hezLogo,
|
|
PEZ: pezLogo,
|
|
USDT: usdtLogo,
|
|
DOT: dotLogo,
|
|
BTC: btcLogo,
|
|
ETH: ethLogo,
|
|
BNB: bnbLogo,
|
|
ADA: adaLogo,
|
|
};
|
|
|
|
// 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 {
|
|
if (__DEV__) console.warn('[TokenService] CoinGecko API error:', response.status);
|
|
}
|
|
} catch (error) {
|
|
if (__DEV__) 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,
|
|
};
|
|
if (__DEV__) console.warn(`[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,
|
|
};
|
|
if (__DEV__) console.warn(`[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) {
|
|
if (__DEV__) 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 unknown as { data: { free: { toString(): string } } };
|
|
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 unknown as { isEmpty?: boolean; isSome?: boolean; unwrap(): { balance: { toString(): string }; status?: { isFrozen?: boolean } } } | null;
|
|
if (assetAccount && !assetAccount.isEmpty && assetAccount.isSome) {
|
|
const accountData = assetAccount.unwrap();
|
|
balanceRaw = BigInt(accountData.balance.toString());
|
|
isFrozen = accountData.status?.isFrozen || false;
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (__DEV__) console.warn(`[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 keyArgs = key.args[0] as unknown as { toNumber(): number };
|
|
const assetId = keyArgs.toNumber();
|
|
|
|
// Skip if already added from known tokens
|
|
if (addedAssetIds.has(assetId)) continue;
|
|
|
|
const metadata = value as unknown as { isEmpty?: boolean; symbol: { toHuman(): string }; name: { toHuman(): string }; decimals: { toNumber(): number } };
|
|
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 unknown as { isEmpty?: boolean; isSome?: boolean; unwrap(): { balance: { toString(): string }; status?: { isFrozen?: boolean } } } | null;
|
|
if (assetAccount && !assetAccount.isEmpty && assetAccount.isSome) {
|
|
const accountData = assetAccount.unwrap();
|
|
balanceRaw = BigInt(accountData.balance.toString());
|
|
isFrozen = accountData.status?.isFrozen || false;
|
|
}
|
|
} catch {
|
|
if (__DEV__) console.warn(`[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 {
|
|
if (__DEV__) console.warn('[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) {
|
|
if (__DEV__) console.warn('[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) {
|
|
if (__DEV__) console.warn('[TokenService] Subscription error:', error);
|
|
}
|
|
|
|
return () => {
|
|
unsubscribes.forEach(unsub => unsub());
|
|
};
|
|
}
|
|
|
|
export function getTokenLogo(symbol: string): ImageSourcePropType | null {
|
|
return TOKEN_LOGOS[symbol] || null;
|
|
}
|