diff --git a/mobile/app.json b/mobile/app.json index a496076a..207c13f7 100644 --- a/mobile/app.json +++ b/mobile/app.json @@ -1,7 +1,7 @@ { "expo": { - "name": "Pezkuwi Wallet", - "slug": "pezkuwi-wallet", + "name": "Pezkuwi", + "slug": "pezkuwi", "version": "1.0.0", "orientation": "portrait", "icon": "./assets/icon.png", diff --git a/mobile/assets/favicon.png b/mobile/assets/favicon.png index e75f697b..445f8f22 100644 Binary files a/mobile/assets/favicon.png and b/mobile/assets/favicon.png differ diff --git a/mobile/src/contexts/PezkuwiContext.tsx b/mobile/src/contexts/PezkuwiContext.tsx index 1e6ec746..0c09e23a 100644 --- a/mobile/src/contexts/PezkuwiContext.tsx +++ b/mobile/src/contexts/PezkuwiContext.tsx @@ -86,7 +86,7 @@ export const NETWORKS: Record = { zombienet: { name: 'zombienet', displayName: 'Zombienet Dev (Alice/Bob)', - rpcEndpoint: 'wss://zombienet-rpc.pezkuwichain.io', + rpcEndpoint: 'wss://beta-rpc.pezkuwichain.io:19944', ss58Format: 42, type: 'dev', }, diff --git a/mobile/src/screens/SwapScreen.tsx b/mobile/src/screens/SwapScreen.tsx index e3c66b09..6f142bd7 100644 --- a/mobile/src/screens/SwapScreen.tsx +++ b/mobile/src/screens/SwapScreen.tsx @@ -18,9 +18,9 @@ import { KurdistanColors } from '../theme/colors'; import { usePezkuwi } from '../contexts/PezkuwiContext'; import { KurdistanSun } from '../components/KurdistanSun'; -// Token Images -const hezLogo = require('../../../shared/images/hez_logo.png'); -const pezLogo = require('../../../shared/images/pez_logo.jpg'); +// Standardized token logos +const hezLogo = require('../../../shared/images/hez_token_512.png'); +const pezLogo = require('../../../shared/images/pez_token_512.png'); const usdtLogo = require('../../../shared/images/USDT(hez)logo.png'); interface TokenInfo { diff --git a/mobile/src/screens/WalletScreen.tsx b/mobile/src/screens/WalletScreen.tsx index 282eca28..9d046690 100644 --- a/mobile/src/screens/WalletScreen.tsx +++ b/mobile/src/screens/WalletScreen.tsx @@ -26,6 +26,7 @@ import { KurdistanColors } from '../theme/colors'; import { usePezkuwi, NetworkType, NETWORKS } from '../contexts/PezkuwiContext'; import { AddTokenModal } from '../components/wallet/AddTokenModal'; import { HezTokenLogo, PezTokenLogo } from '../components/icons'; +import { decodeAddress, checkAddress, encodeAddress } from '@pezkuwi/util-crypto'; // Secure storage helper - same as in PezkuwiContext const secureStorage = { @@ -58,8 +59,9 @@ const showAlert = (title: string, message: string, buttons?: Array<{text: string }; // Token Images - From shared/images -const hezLogo = require('../../../shared/images/hez_logo.png'); -const pezLogo = require('../../../shared/images/pez_logo.jpg'); +// Standardized token logos +const hezLogo = require('../../../shared/images/hez_token_512.png'); +const pezLogo = require('../../../shared/images/pez_token_512.png'); const usdtLogo = require('../../../shared/images/USDT(hez)logo.png'); const dotLogo = require('../../../shared/images/dot.png'); const btcLogo = require('../../../shared/images/bitcoin.png'); @@ -136,6 +138,22 @@ const WalletScreen: React.FC = () => { USDT: '0.00', }); + // Gas fee estimation state + const [estimatedFee, setEstimatedFee] = useState(''); + const [isEstimatingFee, setIsEstimatingFee] = useState(false); + const [addressError, setAddressError] = useState(''); + + // Address Book state + interface SavedAddress { + address: string; + name: string; + lastUsed?: number; + } + const [savedAddresses, setSavedAddresses] = useState([]); + const [addressBookVisible, setAddressBookVisible] = useState(false); + const [saveAddressModalVisible, setSaveAddressModalVisible] = useState(false); + const [newAddressName, setNewAddressName] = useState(''); + const tokens: Token[] = [ { symbol: 'HEZ', @@ -174,58 +192,54 @@ const WalletScreen: React.FC = () => { setIsLoadingBalances(true); try { - // 1. Fetch Balances - const accountInfo = await api.query.system.account(selectedAccount.address); + // 1. Fetch Balances - decode address to raw bytes to avoid SS58 encoding issues + let accountId: Uint8Array; + try { + accountId = decodeAddress(selectedAccount.address); + } catch (e) { + console.warn('[Wallet] Failed to decode address, using raw:', e); + accountId = selectedAccount.address as any; + } + + const accountInfo = await api.query.system.account(accountId); const hezBalance = (Number(accountInfo.data.free.toString()) / 1e12).toFixed(2); let pezBalance = '0.00'; try { if (api.query.assets?.account) { - const pezAsset = await api.query.assets.account(1, selectedAccount.address); + const pezAsset = await api.query.assets.account(1, accountId); if (pezAsset.isSome) pezBalance = (Number(pezAsset.unwrap().balance.toString()) / 1e12).toFixed(2); } - } catch {} + } catch (e) { + console.warn('[Wallet] PEZ balance fetch failed:', e); + } let usdtBalance = '0.00'; try { if (api.query.assets?.account) { // Check ID 1000 first (as per constants), fallback to 2 just in case - let usdtAsset = await api.query.assets.account(1000, selectedAccount.address); + let usdtAsset = await api.query.assets.account(1000, accountId); if (usdtAsset.isNone) { - usdtAsset = await api.query.assets.account(2, selectedAccount.address); + usdtAsset = await api.query.assets.account(2, accountId); } - + if (usdtAsset.isSome) { // USDT uses 6 decimals usually, checking constants or assuming standard - usdtBalance = (Number(usdtAsset.unwrap().balance.toString()) / 1e6).toFixed(2); + usdtBalance = (Number(usdtAsset.unwrap().balance.toString()) / 1e6).toFixed(2); } } - } catch {} + } catch (e) { + console.warn('[Wallet] USDT balance fetch failed:', e); + } setBalances({ HEZ: hezBalance, PEZ: pezBalance, USDT: usdtBalance }); - // 2. Fetch History from Indexer API (MUCH FASTER) + // 2. Fetch History - TODO: Connect to production indexer when available + // For now, skip indexer and show empty history (chain query is too slow for mobile) setIsLoadingHistory(true); - try { - const INDEXER_URL = 'http://172.31.134.70:3001'; // Update this to your local IP for physical device testing - const response = await fetch(`${INDEXER_URL}/api/history/${selectedAccount.address}`); - const data = await response.json(); - - const txList = data.map((tx: any) => ({ - hash: tx.hash, - method: tx.asset_id ? 'transfer' : 'transfer', - section: tx.asset_id ? 'assets' : 'balances', - from: tx.sender, - to: tx.receiver, - amount: tx.amount, - blockNumber: tx.block_number, - isIncoming: tx.receiver === selectedAccount.address, - })); - - setTransactions(txList); - } catch (e) { - console.warn('Indexer API unreachable, history not updated', e); - } + // Indexer disabled until production endpoint is available + // When ready, use: https://indexer.pezkuwichain.io/api/history/${selectedAccount.address} + setTransactions([]); } catch (error) { console.error('Fetch error:', error); @@ -235,11 +249,45 @@ const WalletScreen: React.FC = () => { } }, [api, isApiReady, selectedAccount]); + // Real-time balance subscription useEffect(() => { + if (!api || !isApiReady || !selectedAccount) return; + + let unsubscribe: (() => void) | null = null; + + const subscribeToBalance = async () => { + try { + let accountId: Uint8Array; + try { + accountId = decodeAddress(selectedAccount.address); + } catch { + return; + } + + // Subscribe to balance changes + unsubscribe = await api.query.system.account(accountId, (accountInfo: any) => { + const hezBalance = (Number(accountInfo.data.free.toString()) / 1e12).toFixed(2); + setBalances(prev => ({ ...prev, HEZ: hezBalance })); + console.log('[Wallet] Balance updated via subscription:', hezBalance, 'HEZ'); + }) as unknown as () => void; + } catch (e) { + console.warn('[Wallet] Subscription failed, falling back to polling:', e); + // Fallback to polling if subscription fails + fetchData(); + } + }; + + subscribeToBalance(); + + // Initial fetch for other tokens (PEZ, USDT) fetchData(); - const interval = setInterval(fetchData, 30000); - return () => clearInterval(interval); - }, [fetchData]); + + return () => { + if (unsubscribe) { + unsubscribe(); + } + }; + }, [api, isApiReady, selectedAccount]); const handleTokenPress = (token: Token) => { if (!token.isLive) return; @@ -257,36 +305,178 @@ const WalletScreen: React.FC = () => { setReceiveModalVisible(true); }; + // Load saved addresses from storage + useEffect(() => { + const loadAddressBook = async () => { + try { + const stored = await AsyncStorage.getItem('@pezkuwi_address_book'); + if (stored) { + setSavedAddresses(JSON.parse(stored)); + } + } catch (e) { + console.warn('[Wallet] Failed to load address book:', e); + } + }; + loadAddressBook(); + }, []); + + // Save address to address book + const saveAddress = async (address: string, name: string) => { + try { + const newAddress: SavedAddress = { + address, + name, + lastUsed: Date.now(), + }; + const updated = [...savedAddresses.filter(a => a.address !== address), newAddress]; + setSavedAddresses(updated); + await AsyncStorage.setItem('@pezkuwi_address_book', JSON.stringify(updated)); + showAlert('Saved', `Address "${name}" saved to address book`); + } catch (e) { + console.warn('[Wallet] Failed to save address:', e); + } + }; + + // Delete address from address book + const deleteAddress = async (address: string) => { + try { + const updated = savedAddresses.filter(a => a.address !== address); + setSavedAddresses(updated); + await AsyncStorage.setItem('@pezkuwi_address_book', JSON.stringify(updated)); + } catch (e) { + console.warn('[Wallet] Failed to delete address:', e); + } + }; + + // Select address from address book + const selectSavedAddress = (address: string) => { + setRecipientAddress(address); + setAddressBookVisible(false); + validateAddress(address); + }; + + // Validate address format + const validateAddress = (address: string): boolean => { + if (!address || address.length < 10) { + setAddressError('Address is too short'); + return false; + } + try { + // Try to decode the address - will throw if invalid + decodeAddress(address); + setAddressError(''); + return true; + } catch (e) { + setAddressError('Invalid address format'); + return false; + } + }; + + // Estimate gas fee before sending + const estimateFee = async () => { + if (!api || !isApiReady || !selectedAccount || !recipientAddress || !sendAmount || !selectedToken) { + return; + } + + if (!validateAddress(recipientAddress)) { + return; + } + + setIsEstimatingFee(true); + try { + const decimals = selectedToken.symbol === 'USDT' ? 1e6 : 1e12; + const amountInUnits = BigInt(Math.floor(parseFloat(sendAmount) * decimals)); + + let tx; + if (selectedToken.symbol === 'HEZ') { + tx = api.tx.balances.transferKeepAlive(recipientAddress, amountInUnits); + } else if (selectedToken.assetId !== undefined) { + tx = api.tx.assets.transfer(selectedToken.assetId, recipientAddress, amountInUnits); + } else { + return; + } + + // Get payment info for fee estimation + const paymentInfo = await tx.paymentInfo(selectedAccount.address); + const feeInHez = (Number(paymentInfo.partialFee.toString()) / 1e12).toFixed(6); + setEstimatedFee(feeInHez); + } catch (e) { + console.warn('[Wallet] Fee estimation failed:', e); + setEstimatedFee('~0.001'); // Fallback estimate + } finally { + setIsEstimatingFee(false); + } + }; + + // Auto-estimate fee when inputs change + useEffect(() => { + if (sendModalVisible && recipientAddress && sendAmount && parseFloat(sendAmount) > 0) { + const timer = setTimeout(estimateFee, 500); // Debounce 500ms + return () => clearTimeout(timer); + } + }, [recipientAddress, sendAmount, sendModalVisible, selectedToken]); + const handleConfirmSend = async () => { if (!recipientAddress || !sendAmount || !selectedToken || !selectedAccount || !api) { showAlert('Error', 'Please enter recipient address and amount'); return; } - + + // Validate address before sending + if (!validateAddress(recipientAddress)) { + showAlert('Error', 'Invalid recipient address'); + return; + } + + // Check if amount is valid + const amount = parseFloat(sendAmount); + if (isNaN(amount) || amount <= 0) { + showAlert('Error', 'Please enter a valid amount'); + return; + } + + // Check if user has enough balance + const currentBalance = parseFloat(balances[selectedToken.symbol] || '0'); + const feeEstimate = parseFloat(estimatedFee || '0.001'); + if (selectedToken.symbol === 'HEZ' && amount + feeEstimate > currentBalance) { + showAlert('Error', `Insufficient balance. You need ${(amount + feeEstimate).toFixed(4)} HEZ (including fee)`); + return; + } else if (selectedToken.symbol !== 'HEZ' && amount > currentBalance) { + showAlert('Error', `Insufficient ${selectedToken.symbol} balance`); + return; + } + setIsSending(true); try { const keypair = await getKeyPair(selectedAccount.address); if (!keypair) throw new Error('Failed to load keypair'); - + // Adjust decimals based on token const decimals = selectedToken.symbol === 'USDT' ? 1e6 : 1e12; - const amountInUnits = BigInt(Math.floor(parseFloat(sendAmount) * decimals)); - + const amountInUnits = BigInt(Math.floor(amount * decimals)); + let tx; if (selectedToken.symbol === 'HEZ') { - tx = api.tx.balances.transfer(recipientAddress, amountInUnits); + // Use transferKeepAlive to prevent account from being reaped + tx = api.tx.balances.transferKeepAlive(recipientAddress, amountInUnits); } else if (selectedToken.assetId !== undefined) { tx = api.tx.assets.transfer(selectedToken.assetId, recipientAddress, amountInUnits); } else { throw new Error('Unknown token type'); } - - await tx.signAndSend(keypair, ({ status }) => { + + await tx.signAndSend(keypair, ({ status, events }) => { + if (status.isInBlock) { + console.log('[Wallet] Transaction in block:', status.asInBlock.toHex()); + } if (status.isFinalized) { setSendModalVisible(false); setIsSending(false); - showAlert('Success', 'Transaction Sent!'); - fetchData(); + setRecipientAddress(''); + setSendAmount(''); + setEstimatedFee(''); + showAlert('Success', `Transaction finalized!\nBlock: ${status.asFinalized.toHex().slice(0, 10)}...`); + fetchData(); } }); } catch (e: any) { @@ -561,11 +751,83 @@ const WalletScreen: React.FC = () => { {selectedToken && } - - + + {/* Recipient Address Input with Address Book */} + + { + setRecipientAddress(text); + if (text.length > 10) validateAddress(text); + }} + autoCapitalize="none" + autoCorrect={false} + /> + setAddressBookVisible(true)} + > + 📒 + + + {addressError ? {addressError} : null} + + {/* Save Address Button (if valid new address) */} + {recipientAddress && !addressError && !savedAddresses.find(a => a.address === recipientAddress) && ( + setSaveAddressModalVisible(true)} + > + 💾 Save this address + + )} + + {/* Amount Input */} + + + {/* Gas Fee Preview */} + {(estimatedFee || isEstimatingFee) && ( + + Estimated Fee: + {isEstimatingFee ? ( + + ) : ( + {estimatedFee} HEZ + )} + + )} + + {/* Total (Amount + Fee) */} + {estimatedFee && sendAmount && selectedToken?.symbol === 'HEZ' && ( + + Total (incl. fee): + + {(parseFloat(sendAmount || '0') + parseFloat(estimatedFee || '0')).toFixed(6)} HEZ + + + )} + - setSendModalVisible(false)}>Cancel - + { + setSendModalVisible(false); + setRecipientAddress(''); + setSendAmount(''); + setEstimatedFee(''); + setAddressError(''); + }}>Cancel + {isSending ? 'Sending...' : 'Confirm'} @@ -765,6 +1027,79 @@ const WalletScreen: React.FC = () => { onTokenAdded={fetchData} /> + {/* Address Book Modal */} + setAddressBookVisible(false)}> + + + 📒 Address Book + {savedAddresses.length === 0 ? ( + No saved addresses yet + ) : ( + + {savedAddresses.map((saved) => ( + + selectSavedAddress(saved.address)} + > + {saved.name} + + {saved.address.slice(0, 12)}...{saved.address.slice(-8)} + + + deleteAddress(saved.address)} + > + 🗑️ + + + ))} + + )} + setAddressBookVisible(false)}> + Close + + + + + + {/* Save Address Modal */} + setSaveAddressModalVisible(false)}> + + + 💾 Save Address + {recipientAddress.slice(0, 16)}...{recipientAddress.slice(-12)} + + + { + setSaveAddressModalVisible(false); + setNewAddressName(''); + }}> + Cancel + + { + if (newAddressName.trim()) { + saveAddress(recipientAddress, newAddressName.trim()); + setSaveAddressModalVisible(false); + setNewAddressName(''); + } + }} + > + Save + + + + + + ); }; @@ -1049,6 +1384,132 @@ const styles = StyleSheet.create({ marginVertical: 10, fontFamily: 'monospace' }, + inputError: { + borderWidth: 1, + borderColor: '#EF4444', + backgroundColor: '#FEF2F2', + }, + errorText: { + color: '#EF4444', + fontSize: 12, + marginTop: -8, + marginBottom: 8, + paddingHorizontal: 4, + }, + feePreview: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#F0FDF4', + padding: 12, + borderRadius: 8, + marginBottom: 8, + }, + feeLabel: { + fontSize: 14, + color: '#666', + }, + feeAmount: { + fontSize: 14, + fontWeight: '600', + color: KurdistanColors.kesk, + }, + totalPreview: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: '#FEF3C7', + padding: 12, + borderRadius: 8, + marginBottom: 8, + }, + totalLabel: { + fontSize: 14, + color: '#92400E', + }, + totalAmount: { + fontSize: 14, + fontWeight: 'bold', + color: '#92400E', + }, + btnDisabled: { + backgroundColor: '#9CA3AF', + opacity: 0.7, + }, + // Address Book styles + addressInputRow: { + flexDirection: 'row', + alignItems: 'center', + width: '100%', + marginBottom: 12, + }, + inputFieldFlex: { + flex: 1, + backgroundColor: '#F5F5F5', + padding: 16, + borderRadius: 12, + marginRight: 8, + }, + addressBookButton: { + width: 48, + height: 48, + borderRadius: 12, + backgroundColor: '#F5F5F5', + justifyContent: 'center', + alignItems: 'center', + }, + addressBookIcon: { + fontSize: 24, + }, + saveAddressLink: { + alignSelf: 'flex-start', + marginTop: -8, + marginBottom: 8, + }, + saveAddressLinkText: { + fontSize: 13, + color: KurdistanColors.kesk, + }, + emptyAddressBook: { + color: '#999', + textAlign: 'center', + paddingVertical: 32, + }, + addressList: { + width: '100%', + maxHeight: 300, + marginBottom: 16, + }, + savedAddressRow: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: '#F8F9FA', + padding: 12, + borderRadius: 12, + marginBottom: 8, + }, + savedAddressInfo: { + flex: 1, + }, + savedAddressName: { + fontSize: 16, + fontWeight: '600', + color: '#333', + marginBottom: 2, + }, + savedAddressAddr: { + fontSize: 12, + color: '#999', + fontFamily: 'monospace', + }, + deleteAddressButton: { + width: 36, + height: 36, + borderRadius: 8, + backgroundColor: 'rgba(239, 68, 68, 0.1)', + justifyContent: 'center', + alignItems: 'center', + }, // Network Selector Styles networkOption: { diff --git a/shared/constants/index.ts b/shared/constants/index.ts index 23943c48..32fdc248 100644 --- a/shared/constants/index.ts +++ b/shared/constants/index.ts @@ -55,14 +55,14 @@ export const KNOWN_TOKENS: Record = { symbol: 'wHEZ', name: 'Wrapped HEZ', decimals: 12, - logo: '/shared/images/hez_logo.png', + logo: '/shared/images/hez_token_512.png', }, 1: { id: 1, symbol: 'PEZ', name: 'Pezkuwi Token', decimals: 12, - logo: '/shared/images/pez_logo.jpg', + logo: '/shared/images/pez_token_512.png', }, 1000: { id: 1000, diff --git a/shared/images/hez_token_512.png b/shared/images/hez_token_512.png new file mode 100644 index 00000000..c46fa74f Binary files /dev/null and b/shared/images/hez_token_512.png differ diff --git a/shared/images/pez_token_512.png b/shared/images/pez_token_512.png new file mode 100644 index 00000000..e86d909a Binary files /dev/null and b/shared/images/pez_token_512.png differ