mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-06-12 21:21:02 +00:00
feat(wallet): Production-ready wallet with real blockchain integration
Wallet Features: - Fix address encoding (48 byte → 32 byte) for chain queries - Add gas fee preview before sending transactions - Add address validation with error display - Add real-time balance via chain subscription - Add address book (save/load frequently used addresses) - Add balance check before send (insufficient funds warning) - Use transferKeepAlive to prevent account reaping UI/UX: - Browser title: "Pezkuwi Wallet" → "Pezkuwi" - Update favicon from shared/images - Standardize token logos (hez_token_512.png, pez_token_512.png) Infrastructure: - Fix zombienet endpoint: wss://beta-rpc.pezkuwichain.io:19944 - Disable indexer API (not production ready yet) - Optimize logo images for mobile (5MB → 300KB)
This commit is contained in:
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"expo": {
|
"expo": {
|
||||||
"name": "Pezkuwi Wallet",
|
"name": "Pezkuwi",
|
||||||
"slug": "pezkuwi-wallet",
|
"slug": "pezkuwi",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 274 KiB |
@@ -86,7 +86,7 @@ export const NETWORKS: Record<NetworkType, NetworkConfig> = {
|
|||||||
zombienet: {
|
zombienet: {
|
||||||
name: 'zombienet',
|
name: 'zombienet',
|
||||||
displayName: 'Zombienet Dev (Alice/Bob)',
|
displayName: 'Zombienet Dev (Alice/Bob)',
|
||||||
rpcEndpoint: 'wss://zombienet-rpc.pezkuwichain.io',
|
rpcEndpoint: 'wss://beta-rpc.pezkuwichain.io:19944',
|
||||||
ss58Format: 42,
|
ss58Format: 42,
|
||||||
type: 'dev',
|
type: 'dev',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ import { KurdistanColors } from '../theme/colors';
|
|||||||
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
import { usePezkuwi } from '../contexts/PezkuwiContext';
|
||||||
import { KurdistanSun } from '../components/KurdistanSun';
|
import { KurdistanSun } from '../components/KurdistanSun';
|
||||||
|
|
||||||
// Token Images
|
// Standardized token logos
|
||||||
const hezLogo = require('../../../shared/images/hez_logo.png');
|
const hezLogo = require('../../../shared/images/hez_token_512.png');
|
||||||
const pezLogo = require('../../../shared/images/pez_logo.jpg');
|
const pezLogo = require('../../../shared/images/pez_token_512.png');
|
||||||
const usdtLogo = require('../../../shared/images/USDT(hez)logo.png');
|
const usdtLogo = require('../../../shared/images/USDT(hez)logo.png');
|
||||||
|
|
||||||
interface TokenInfo {
|
interface TokenInfo {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import { KurdistanColors } from '../theme/colors';
|
|||||||
import { usePezkuwi, NetworkType, NETWORKS } from '../contexts/PezkuwiContext';
|
import { usePezkuwi, NetworkType, NETWORKS } from '../contexts/PezkuwiContext';
|
||||||
import { AddTokenModal } from '../components/wallet/AddTokenModal';
|
import { AddTokenModal } from '../components/wallet/AddTokenModal';
|
||||||
import { HezTokenLogo, PezTokenLogo } from '../components/icons';
|
import { HezTokenLogo, PezTokenLogo } from '../components/icons';
|
||||||
|
import { decodeAddress, checkAddress, encodeAddress } from '@pezkuwi/util-crypto';
|
||||||
|
|
||||||
// Secure storage helper - same as in PezkuwiContext
|
// Secure storage helper - same as in PezkuwiContext
|
||||||
const secureStorage = {
|
const secureStorage = {
|
||||||
@@ -58,8 +59,9 @@ const showAlert = (title: string, message: string, buttons?: Array<{text: string
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Token Images - From shared/images
|
// Token Images - From shared/images
|
||||||
const hezLogo = require('../../../shared/images/hez_logo.png');
|
// Standardized token logos
|
||||||
const pezLogo = require('../../../shared/images/pez_logo.jpg');
|
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 usdtLogo = require('../../../shared/images/USDT(hez)logo.png');
|
||||||
const dotLogo = require('../../../shared/images/dot.png');
|
const dotLogo = require('../../../shared/images/dot.png');
|
||||||
const btcLogo = require('../../../shared/images/bitcoin.png');
|
const btcLogo = require('../../../shared/images/bitcoin.png');
|
||||||
@@ -136,6 +138,22 @@ const WalletScreen: React.FC = () => {
|
|||||||
USDT: '0.00',
|
USDT: '0.00',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Gas fee estimation state
|
||||||
|
const [estimatedFee, setEstimatedFee] = useState<string>('');
|
||||||
|
const [isEstimatingFee, setIsEstimatingFee] = useState(false);
|
||||||
|
const [addressError, setAddressError] = useState<string>('');
|
||||||
|
|
||||||
|
// Address Book state
|
||||||
|
interface SavedAddress {
|
||||||
|
address: string;
|
||||||
|
name: string;
|
||||||
|
lastUsed?: number;
|
||||||
|
}
|
||||||
|
const [savedAddresses, setSavedAddresses] = useState<SavedAddress[]>([]);
|
||||||
|
const [addressBookVisible, setAddressBookVisible] = useState(false);
|
||||||
|
const [saveAddressModalVisible, setSaveAddressModalVisible] = useState(false);
|
||||||
|
const [newAddressName, setNewAddressName] = useState('');
|
||||||
|
|
||||||
const tokens: Token[] = [
|
const tokens: Token[] = [
|
||||||
{
|
{
|
||||||
symbol: 'HEZ',
|
symbol: 'HEZ',
|
||||||
@@ -174,58 +192,54 @@ const WalletScreen: React.FC = () => {
|
|||||||
|
|
||||||
setIsLoadingBalances(true);
|
setIsLoadingBalances(true);
|
||||||
try {
|
try {
|
||||||
// 1. Fetch Balances
|
// 1. Fetch Balances - decode address to raw bytes to avoid SS58 encoding issues
|
||||||
const accountInfo = await api.query.system.account(selectedAccount.address);
|
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);
|
const hezBalance = (Number(accountInfo.data.free.toString()) / 1e12).toFixed(2);
|
||||||
|
|
||||||
let pezBalance = '0.00';
|
let pezBalance = '0.00';
|
||||||
try {
|
try {
|
||||||
if (api.query.assets?.account) {
|
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);
|
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';
|
let usdtBalance = '0.00';
|
||||||
try {
|
try {
|
||||||
if (api.query.assets?.account) {
|
if (api.query.assets?.account) {
|
||||||
// Check ID 1000 first (as per constants), fallback to 2 just in case
|
// 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) {
|
if (usdtAsset.isNone) {
|
||||||
usdtAsset = await api.query.assets.account(2, selectedAccount.address);
|
usdtAsset = await api.query.assets.account(2, accountId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usdtAsset.isSome) {
|
if (usdtAsset.isSome) {
|
||||||
// USDT uses 6 decimals usually, checking constants or assuming standard
|
// 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 });
|
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);
|
setIsLoadingHistory(true);
|
||||||
try {
|
// Indexer disabled until production endpoint is available
|
||||||
const INDEXER_URL = 'http://172.31.134.70:3001'; // Update this to your local IP for physical device testing
|
// When ready, use: https://indexer.pezkuwichain.io/api/history/${selectedAccount.address}
|
||||||
const response = await fetch(`${INDEXER_URL}/api/history/${selectedAccount.address}`);
|
setTransactions([]);
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Fetch error:', error);
|
console.error('Fetch error:', error);
|
||||||
@@ -235,11 +249,45 @@ const WalletScreen: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [api, isApiReady, selectedAccount]);
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
|
// Real-time balance subscription
|
||||||
useEffect(() => {
|
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();
|
fetchData();
|
||||||
const interval = setInterval(fetchData, 30000);
|
|
||||||
return () => clearInterval(interval);
|
return () => {
|
||||||
}, [fetchData]);
|
if (unsubscribe) {
|
||||||
|
unsubscribe();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [api, isApiReady, selectedAccount]);
|
||||||
|
|
||||||
const handleTokenPress = (token: Token) => {
|
const handleTokenPress = (token: Token) => {
|
||||||
if (!token.isLive) return;
|
if (!token.isLive) return;
|
||||||
@@ -257,36 +305,178 @@ const WalletScreen: React.FC = () => {
|
|||||||
setReceiveModalVisible(true);
|
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 () => {
|
const handleConfirmSend = async () => {
|
||||||
if (!recipientAddress || !sendAmount || !selectedToken || !selectedAccount || !api) {
|
if (!recipientAddress || !sendAmount || !selectedToken || !selectedAccount || !api) {
|
||||||
showAlert('Error', 'Please enter recipient address and amount');
|
showAlert('Error', 'Please enter recipient address and amount');
|
||||||
return;
|
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);
|
setIsSending(true);
|
||||||
try {
|
try {
|
||||||
const keypair = await getKeyPair(selectedAccount.address);
|
const keypair = await getKeyPair(selectedAccount.address);
|
||||||
if (!keypair) throw new Error('Failed to load keypair');
|
if (!keypair) throw new Error('Failed to load keypair');
|
||||||
|
|
||||||
// Adjust decimals based on token
|
// Adjust decimals based on token
|
||||||
const decimals = selectedToken.symbol === 'USDT' ? 1e6 : 1e12;
|
const decimals = selectedToken.symbol === 'USDT' ? 1e6 : 1e12;
|
||||||
const amountInUnits = BigInt(Math.floor(parseFloat(sendAmount) * decimals));
|
const amountInUnits = BigInt(Math.floor(amount * decimals));
|
||||||
|
|
||||||
let tx;
|
let tx;
|
||||||
if (selectedToken.symbol === 'HEZ') {
|
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) {
|
} else if (selectedToken.assetId !== undefined) {
|
||||||
tx = api.tx.assets.transfer(selectedToken.assetId, recipientAddress, amountInUnits);
|
tx = api.tx.assets.transfer(selectedToken.assetId, recipientAddress, amountInUnits);
|
||||||
} else {
|
} else {
|
||||||
throw new Error('Unknown token type');
|
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) {
|
if (status.isFinalized) {
|
||||||
setSendModalVisible(false);
|
setSendModalVisible(false);
|
||||||
setIsSending(false);
|
setIsSending(false);
|
||||||
showAlert('Success', 'Transaction Sent!');
|
setRecipientAddress('');
|
||||||
fetchData();
|
setSendAmount('');
|
||||||
|
setEstimatedFee('');
|
||||||
|
showAlert('Success', `Transaction finalized!\nBlock: ${status.asFinalized.toHex().slice(0, 10)}...`);
|
||||||
|
fetchData();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -561,11 +751,83 @@ const WalletScreen: React.FC = () => {
|
|||||||
<View style={{alignItems:'center', marginBottom:16}}>
|
<View style={{alignItems:'center', marginBottom:16}}>
|
||||||
{selectedToken && <Image source={selectedToken.logo} style={{width:48, height:48}} />}
|
{selectedToken && <Image source={selectedToken.logo} style={{width:48, height:48}} />}
|
||||||
</View>
|
</View>
|
||||||
<TextInput style={styles.inputField} placeholder="Address" value={recipientAddress} onChangeText={setRecipientAddress} />
|
|
||||||
<TextInput style={styles.inputField} placeholder="Amount" keyboardType="numeric" value={sendAmount} onChangeText={setSendAmount} />
|
{/* Recipient Address Input with Address Book */}
|
||||||
|
<View style={styles.addressInputRow}>
|
||||||
|
<TextInput
|
||||||
|
style={[styles.inputFieldFlex, addressError ? styles.inputError : null]}
|
||||||
|
placeholder="Recipient Address"
|
||||||
|
value={recipientAddress}
|
||||||
|
onChangeText={(text) => {
|
||||||
|
setRecipientAddress(text);
|
||||||
|
if (text.length > 10) validateAddress(text);
|
||||||
|
}}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
/>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.addressBookButton}
|
||||||
|
onPress={() => setAddressBookVisible(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.addressBookIcon}>📒</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
{addressError ? <Text style={styles.errorText}>{addressError}</Text> : null}
|
||||||
|
|
||||||
|
{/* Save Address Button (if valid new address) */}
|
||||||
|
{recipientAddress && !addressError && !savedAddresses.find(a => a.address === recipientAddress) && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.saveAddressLink}
|
||||||
|
onPress={() => setSaveAddressModalVisible(true)}
|
||||||
|
>
|
||||||
|
<Text style={styles.saveAddressLinkText}>💾 Save this address</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Amount Input */}
|
||||||
|
<TextInput
|
||||||
|
style={styles.inputField}
|
||||||
|
placeholder={`Amount (Balance: ${balances[selectedToken?.symbol || 'HEZ']} ${selectedToken?.symbol})`}
|
||||||
|
keyboardType="numeric"
|
||||||
|
value={sendAmount}
|
||||||
|
onChangeText={setSendAmount}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Gas Fee Preview */}
|
||||||
|
{(estimatedFee || isEstimatingFee) && (
|
||||||
|
<View style={styles.feePreview}>
|
||||||
|
<Text style={styles.feeLabel}>Estimated Fee:</Text>
|
||||||
|
{isEstimatingFee ? (
|
||||||
|
<ActivityIndicator size="small" color={KurdistanColors.kesk} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.feeAmount}>{estimatedFee} HEZ</Text>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Total (Amount + Fee) */}
|
||||||
|
{estimatedFee && sendAmount && selectedToken?.symbol === 'HEZ' && (
|
||||||
|
<View style={styles.totalPreview}>
|
||||||
|
<Text style={styles.totalLabel}>Total (incl. fee):</Text>
|
||||||
|
<Text style={styles.totalAmount}>
|
||||||
|
{(parseFloat(sendAmount || '0') + parseFloat(estimatedFee || '0')).toFixed(6)} HEZ
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.modalActions}>
|
<View style={styles.modalActions}>
|
||||||
<TouchableOpacity style={styles.btnCancel} onPress={() => setSendModalVisible(false)}><Text>Cancel</Text></TouchableOpacity>
|
<TouchableOpacity style={styles.btnCancel} onPress={() => {
|
||||||
<TouchableOpacity style={styles.btnConfirm} onPress={handleConfirmSend} disabled={isSending}>
|
setSendModalVisible(false);
|
||||||
|
setRecipientAddress('');
|
||||||
|
setSendAmount('');
|
||||||
|
setEstimatedFee('');
|
||||||
|
setAddressError('');
|
||||||
|
}}><Text>Cancel</Text></TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.btnConfirm, (isSending || !!addressError) && styles.btnDisabled]}
|
||||||
|
onPress={handleConfirmSend}
|
||||||
|
disabled={isSending || !!addressError}
|
||||||
|
>
|
||||||
<Text style={{color:'white'}}>{isSending ? 'Sending...' : 'Confirm'}</Text>
|
<Text style={{color:'white'}}>{isSending ? 'Sending...' : 'Confirm'}</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
@@ -765,6 +1027,79 @@ const WalletScreen: React.FC = () => {
|
|||||||
onTokenAdded={fetchData}
|
onTokenAdded={fetchData}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Address Book Modal */}
|
||||||
|
<Modal visible={addressBookVisible} transparent animationType="slide" onRequestClose={() => setAddressBookVisible(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalHeader}>📒 Address Book</Text>
|
||||||
|
{savedAddresses.length === 0 ? (
|
||||||
|
<Text style={styles.emptyAddressBook}>No saved addresses yet</Text>
|
||||||
|
) : (
|
||||||
|
<ScrollView style={styles.addressList}>
|
||||||
|
{savedAddresses.map((saved) => (
|
||||||
|
<View key={saved.address} style={styles.savedAddressRow}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.savedAddressInfo}
|
||||||
|
onPress={() => selectSavedAddress(saved.address)}
|
||||||
|
>
|
||||||
|
<Text style={styles.savedAddressName}>{saved.name}</Text>
|
||||||
|
<Text style={styles.savedAddressAddr} numberOfLines={1}>
|
||||||
|
{saved.address.slice(0, 12)}...{saved.address.slice(-8)}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.deleteAddressButton}
|
||||||
|
onPress={() => deleteAddress(saved.address)}
|
||||||
|
>
|
||||||
|
<Text>🗑️</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</ScrollView>
|
||||||
|
)}
|
||||||
|
<TouchableOpacity style={styles.btnConfirm} onPress={() => setAddressBookVisible(false)}>
|
||||||
|
<Text style={{color:'white'}}>Close</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Save Address Modal */}
|
||||||
|
<Modal visible={saveAddressModalVisible} transparent animationType="slide" onRequestClose={() => setSaveAddressModalVisible(false)}>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalCard}>
|
||||||
|
<Text style={styles.modalHeader}>💾 Save Address</Text>
|
||||||
|
<Text style={styles.savedAddressAddr}>{recipientAddress.slice(0, 16)}...{recipientAddress.slice(-12)}</Text>
|
||||||
|
<TextInput
|
||||||
|
style={styles.inputField}
|
||||||
|
placeholder="Name (e.g. Alice, Exchange)"
|
||||||
|
value={newAddressName}
|
||||||
|
onChangeText={setNewAddressName}
|
||||||
|
/>
|
||||||
|
<View style={styles.modalActions}>
|
||||||
|
<TouchableOpacity style={styles.btnCancel} onPress={() => {
|
||||||
|
setSaveAddressModalVisible(false);
|
||||||
|
setNewAddressName('');
|
||||||
|
}}>
|
||||||
|
<Text>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.btnConfirm}
|
||||||
|
onPress={() => {
|
||||||
|
if (newAddressName.trim()) {
|
||||||
|
saveAddress(recipientAddress, newAddressName.trim());
|
||||||
|
setSaveAddressModalVisible(false);
|
||||||
|
setNewAddressName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{color:'white'}}>Save</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
</SafeAreaView>
|
</SafeAreaView>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1049,6 +1384,132 @@ const styles = StyleSheet.create({
|
|||||||
marginVertical: 10,
|
marginVertical: 10,
|
||||||
fontFamily: 'monospace'
|
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
|
// Network Selector Styles
|
||||||
networkOption: {
|
networkOption: {
|
||||||
|
|||||||
@@ -55,14 +55,14 @@ export const KNOWN_TOKENS: Record<number, TokenInfo> = {
|
|||||||
symbol: 'wHEZ',
|
symbol: 'wHEZ',
|
||||||
name: 'Wrapped HEZ',
|
name: 'Wrapped HEZ',
|
||||||
decimals: 12,
|
decimals: 12,
|
||||||
logo: '/shared/images/hez_logo.png',
|
logo: '/shared/images/hez_token_512.png',
|
||||||
},
|
},
|
||||||
1: {
|
1: {
|
||||||
id: 1,
|
id: 1,
|
||||||
symbol: 'PEZ',
|
symbol: 'PEZ',
|
||||||
name: 'Pezkuwi Token',
|
name: 'Pezkuwi Token',
|
||||||
decimals: 12,
|
decimals: 12,
|
||||||
logo: '/shared/images/pez_logo.jpg',
|
logo: '/shared/images/pez_token_512.png',
|
||||||
},
|
},
|
||||||
1000: {
|
1000: {
|
||||||
id: 1000,
|
id: 1000,
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 314 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
Reference in New Issue
Block a user