Files
pwap/web/src/components/AddLiquidityModal.tsx
T
Claude 7b95b8a409 Centralize common code in shared folder
This commit reorganizes the codebase to eliminate duplication between web and mobile frontends by moving all commonly used files to the shared folder.

Changes:
- Moved lib files to shared/lib/:
  * wallet.ts, staking.ts, tiki.ts, identity.ts
  * multisig.ts, usdt.ts, scores.ts, citizenship-workflow.ts

- Moved utils to shared/utils/:
  * auth.ts, dex.ts
  * Created format.ts (extracted formatNumber from web utils)

- Created shared/theme/:
  * colors.ts (Kurdistan and App color definitions)

- Updated web configuration:
  * Added @pezkuwi/* path aliases in tsconfig.json and vite.config.ts
  * Updated all imports to use @pezkuwi/lib/*, @pezkuwi/utils/*, @pezkuwi/theme/*
  * Removed duplicate files from web/src/lib and web/src/utils

- Updated mobile configuration:
  * Added @pezkuwi/* path aliases in tsconfig.json
  * Updated theme/colors.ts to re-export from shared
  * Mobile already uses relative imports to shared (no changes needed)

Architecture Benefits:
- Single source of truth for common code
- No duplication between frontends
- Easier maintenance and consistency
- Clear separation of shared vs platform-specific code

Web-specific files kept:
- web/src/lib/supabase.ts
- web/src/lib/utils.ts (cn function for Tailwind, re-exports formatNumber from shared)

All imports updated and tested. Both web and mobile now use the centralized shared folder.
2025-11-14 22:46:39 +00:00

539 lines
21 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect } from 'react';
import { X, Plus, Info, AlertCircle } from 'lucide-react';
import { web3FromAddress } from '@polkadot/extension-dapp';
import { usePolkadot } from '@/contexts/PolkadotContext';
import { useWallet } from '@/contexts/WalletContext';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet';
interface AddLiquidityModalProps {
isOpen: boolean;
onClose: () => void;
asset0?: number; // Pool's first asset ID
asset1?: number; // Pool's second asset ID
}
// Helper to get display name (users see HEZ not wHEZ, PEZ, USDT not wUSDT)
const getDisplayName = (assetId: number): string => {
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
if (assetId === ASSET_IDS.WUSDT || assetId === 2) return 'USDT';
return getAssetSymbol(assetId);
};
// Helper to get balance key for the asset
const getBalanceKey = (assetId: number): string => {
if (assetId === ASSET_IDS.WHEZ || assetId === 0) return 'HEZ';
if (assetId === ASSET_IDS.PEZ || assetId === 1) return 'PEZ';
if (assetId === ASSET_IDS.WUSDT || assetId === 2) return 'USDT';
return getAssetSymbol(assetId);
};
// Helper to get decimals for asset
const getAssetDecimals = (assetId: number): number => {
if (assetId === ASSET_IDS.WUSDT || assetId === 2) return 6; // wUSDT has 6 decimals
return 12; // wHEZ, PEZ have 12 decimals
};
export const AddLiquidityModal: React.FC<AddLiquidityModalProps> = ({
isOpen,
onClose,
asset0 = 0, // Default to wHEZ
asset1 = 1 // Default to PEZ
}) => {
const { api, selectedAccount, isApiReady } = usePolkadot();
const { balances, refreshBalances } = useWallet();
const [amount0, setAmount0] = useState('');
const [amount1, setAmount1] = useState('');
const [currentPrice, setCurrentPrice] = useState<number | null>(null);
const [isPoolEmpty, setIsPoolEmpty] = useState(true); // Track if pool has meaningful liquidity
const [minDeposit0, setMinDeposit0] = useState<number>(0.01); // Dynamic minimum deposit for asset0
const [minDeposit1, setMinDeposit1] = useState<number>(0.01); // Dynamic minimum deposit for asset1
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
// Get asset details
const asset0Name = getDisplayName(asset0);
const asset1Name = getDisplayName(asset1);
const asset0BalanceKey = getBalanceKey(asset0);
const asset1BalanceKey = getBalanceKey(asset1);
const asset0Decimals = getAssetDecimals(asset0);
const asset1Decimals = getAssetDecimals(asset1);
// Reset form when modal is closed
useEffect(() => {
if (!isOpen) {
setAmount0('');
setAmount1('');
setError(null);
setSuccess(false);
}
}, [isOpen]);
// Fetch minimum deposit requirements from runtime
useEffect(() => {
if (!api || !isApiReady || !isOpen) return;
const fetchMinimumBalances = async () => {
try {
// Query asset details which contains minBalance
const assetDetails0 = await api.query.assets.asset(asset0);
const assetDetails1 = await api.query.assets.asset(asset1);
console.log('🔍 Querying minimum balances for assets:', { asset0, asset1 });
if (assetDetails0.isSome && assetDetails1.isSome) {
const details0 = assetDetails0.unwrap().toJSON() as any;
const details1 = assetDetails1.unwrap().toJSON() as any;
console.log('📦 Asset details:', {
asset0: details0,
asset1: details1
});
const minBalance0Raw = details0.minBalance || '0';
const minBalance1Raw = details1.minBalance || '0';
const minBalance0 = Number(minBalance0Raw) / Math.pow(10, asset0Decimals);
const minBalance1 = Number(minBalance1Raw) / Math.pow(10, asset1Decimals);
console.log('📊 Minimum deposit requirements from assets:', {
asset0: asset0Name,
minBalance0Raw,
minBalance0,
asset1: asset1Name,
minBalance1Raw,
minBalance1
});
setMinDeposit0(minBalance0);
setMinDeposit1(minBalance1);
} else {
console.warn('⚠️ Asset details not found, using defaults');
}
// Also check if there's a MintMinLiquidity constant in assetConversion pallet
if (api.consts.assetConversion) {
const mintMinLiq = api.consts.assetConversion.mintMinLiquidity;
if (mintMinLiq) {
console.log('🔧 AssetConversion MintMinLiquidity constant:', mintMinLiq.toString());
}
const liquidityWithdrawalFee = api.consts.assetConversion.liquidityWithdrawalFee;
if (liquidityWithdrawalFee) {
console.log('🔧 AssetConversion LiquidityWithdrawalFee:', liquidityWithdrawalFee.toHuman());
}
// Log all assetConversion constants
console.log('🔧 All assetConversion constants:', Object.keys(api.consts.assetConversion));
}
} catch (err) {
console.error('❌ Error fetching minimum balances:', err);
// Keep default 0.01 if query fails
}
};
fetchMinimumBalances();
}, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals, asset0Name, asset1Name]);
// Fetch current pool price
useEffect(() => {
if (!api || !isApiReady || !isOpen) return;
const fetchPoolPrice = async () => {
try {
const poolId = [asset0, asset1];
const poolInfo = await api.query.assetConversion.pools(poolId);
if (poolInfo.isSome) {
// Derive pool account using AccountIdConverter
const { stringToU8a } = await import('@polkadot/util');
const { blake2AsU8a } = await import('@polkadot/util-crypto');
const PALLET_ID = stringToU8a('py/ascon');
const poolIdType = api.createType('(u32, u32)', [asset0, asset1]);
const palletIdType = api.createType('[u8; 8]', PALLET_ID);
const fullTuple = api.createType('([u8; 8], (u32, u32))', [palletIdType, poolIdType]);
const accountHash = blake2AsU8a(fullTuple.toU8a(), 256);
const poolAccountId = api.createType('AccountId32', accountHash);
// Get reserves
const balance0Data = await api.query.assets.account(asset0, poolAccountId);
const balance1Data = await api.query.assets.account(asset1, poolAccountId);
if (balance0Data.isSome && balance1Data.isSome) {
const data0 = balance0Data.unwrap().toJSON() as any;
const data1 = balance1Data.unwrap().toJSON() as any;
const reserve0 = Number(data0.balance) / Math.pow(10, asset0Decimals);
const reserve1 = Number(data1.balance) / Math.pow(10, asset1Decimals);
// Consider pool empty if reserves are less than 1 token (dust amounts)
const MINIMUM_LIQUIDITY = 1;
if (reserve0 >= MINIMUM_LIQUIDITY && reserve1 >= MINIMUM_LIQUIDITY) {
setCurrentPrice(reserve1 / reserve0);
setIsPoolEmpty(false);
console.log('Pool has liquidity - auto-calculating ratio:', reserve1 / reserve0);
} else {
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool is empty or has dust only - manual input allowed');
}
} else {
// No reserves found - pool is empty
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool is empty - manual input allowed');
}
} else {
// Pool doesn't exist yet - completely empty
setCurrentPrice(null);
setIsPoolEmpty(true);
console.log('Pool does not exist yet - manual input allowed');
}
} catch (err) {
console.error('Error fetching pool price:', err);
// On error, assume pool is empty to allow manual input
setCurrentPrice(null);
setIsPoolEmpty(true);
}
};
fetchPoolPrice();
}, [api, isApiReady, isOpen, asset0, asset1, asset0Decimals, asset1Decimals]);
// Auto-calculate asset1 amount based on asset0 input (only if pool has liquidity)
useEffect(() => {
if (!isPoolEmpty && amount0 && currentPrice) {
const calculated = parseFloat(amount0) * currentPrice;
setAmount1(calculated.toFixed(asset1Decimals === 6 ? 2 : 4));
} else if (!amount0 && !isPoolEmpty) {
setAmount1('');
}
// If pool is empty, don't auto-calculate - let user input both amounts
}, [amount0, currentPrice, asset1Decimals, isPoolEmpty]);
const handleAddLiquidity = async () => {
if (!api || !selectedAccount || !amount0 || !amount1) return;
setIsLoading(true);
setError(null);
try {
// Validate amounts
if (parseFloat(amount0) <= 0 || parseFloat(amount1) <= 0) {
setError('Please enter valid amounts');
setIsLoading(false);
return;
}
// Check minimum deposit requirements from runtime
if (parseFloat(amount0) < minDeposit0) {
setError(`${asset0Name} amount must be at least ${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
setIsLoading(false);
return;
}
if (parseFloat(amount1) < minDeposit1) {
setError(`${asset1Name} amount must be at least ${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} (minimum deposit requirement)`);
setIsLoading(false);
return;
}
const balance0 = (balances as any)[asset0BalanceKey] || 0;
const balance1 = (balances as any)[asset1BalanceKey] || 0;
if (parseFloat(amount0) > balance0) {
setError(`Insufficient ${asset0Name} balance`);
setIsLoading(false);
return;
}
if (parseFloat(amount1) > balance1) {
setError(`Insufficient ${asset1Name} balance`);
setIsLoading(false);
return;
}
// Get the signer from the extension
const injector = await web3FromAddress(selectedAccount.address);
// Convert amounts to proper decimals
const amount0BN = BigInt(Math.floor(parseFloat(amount0) * Math.pow(10, asset0Decimals)));
const amount1BN = BigInt(Math.floor(parseFloat(amount1) * Math.pow(10, asset1Decimals)));
// Min amounts (90% of desired to account for slippage)
const minAmount0BN = (amount0BN * BigInt(90)) / BigInt(100);
const minAmount1BN = (amount1BN * BigInt(90)) / BigInt(100);
// Build transaction(s)
let tx;
// If asset0 is HEZ (0), need to wrap it first
if (asset0 === 0 || asset0 === ASSET_IDS.WHEZ) {
const wrapTx = api.tx.tokenWrapper.wrap(amount0BN.toString());
const addLiquidityTx = api.tx.assetConversion.addLiquidity(
asset0,
asset1,
amount0BN.toString(),
amount1BN.toString(),
minAmount0BN.toString(),
minAmount1BN.toString(),
selectedAccount.address
);
// Batch wrap + add liquidity
tx = api.tx.utility.batchAll([wrapTx, addLiquidityTx]);
} else {
// Direct add liquidity (no wrapping needed)
tx = api.tx.assetConversion.addLiquidity(
asset0,
asset1,
amount0BN.toString(),
amount1BN.toString(),
minAmount0BN.toString(),
minAmount1BN.toString(),
selectedAccount.address
);
}
await tx.signAndSend(
selectedAccount.address,
{ signer: injector.signer },
({ status, events, dispatchError }) => {
if (status.isInBlock) {
console.log('Transaction in block:', status.asInBlock.toHex());
} else if (status.isFinalized) {
console.log('Transaction finalized:', status.asFinalized.toHex());
// Check for errors
const hasError = events.some(({ event }) =>
api.events.system.ExtrinsicFailed.is(event)
);
if (hasError || dispatchError) {
let errorMessage = 'Transaction failed';
if (dispatchError) {
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
const { docs, name, section } = decoded;
errorMessage = `${section}.${name}: ${docs.join(' ')}`;
console.error('Dispatch error:', errorMessage);
} else {
errorMessage = dispatchError.toString();
console.error('Dispatch error:', errorMessage);
}
}
events.forEach(({ event }) => {
if (api.events.system.ExtrinsicFailed.is(event)) {
console.error('ExtrinsicFailed event:', event.toHuman());
}
});
setError(errorMessage);
setIsLoading(false);
} else {
console.log('Transaction successful');
setSuccess(true);
setIsLoading(false);
setAmount0('');
setAmount1('');
refreshBalances();
setTimeout(() => {
setSuccess(false);
onClose();
}, 2000);
}
}
}
);
} catch (err) {
console.error('Error adding liquidity:', err);
setError(err instanceof Error ? err.message : 'Failed to add liquidity');
setIsLoading(false);
}
};
if (!isOpen) return null;
const balance0 = (balances as any)[asset0BalanceKey] || 0;
const balance1 = (balances as any)[asset1BalanceKey] || 0;
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-gray-900 rounded-lg max-w-md w-full p-6 border border-gray-700">
<div className="flex justify-between items-center mb-6">
<h2 className="text-2xl font-bold text-white">Add Liquidity</h2>
<button
onClick={onClose}
className="text-gray-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{error && (
<Alert className="mb-4 bg-red-900/20 border-red-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{success && (
<Alert className="mb-4 bg-green-900/20 border-green-500">
<AlertDescription>Liquidity added successfully!</AlertDescription>
</Alert>
)}
{isPoolEmpty ? (
<Alert className="mb-4 bg-yellow-900/20 border-yellow-500">
<AlertCircle className="h-4 w-4" />
<AlertDescription className="text-sm">
<strong>First Liquidity Provider:</strong> Pool is empty! You are setting the initial price ratio.
<strong> Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}.</strong>
{(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'}
</AlertDescription>
</Alert>
) : (
<Alert className="mb-4 bg-blue-900/20 border-blue-500">
<Info className="h-4 w-4" />
<AlertDescription className="text-sm">
Add liquidity to earn 3% fees from all swaps. Amounts are auto-calculated based on current pool ratio.
<strong> Minimum deposit: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} {asset0Name} and {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}.</strong>
{(asset0 === 0 || asset0 === ASSET_IDS.WHEZ) && ' Your HEZ will be automatically wrapped to wHEZ.'}
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
{/* Asset 0 Input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{asset0Name} Amount
<span className="text-xs text-gray-500 ml-2">(min: {minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)})</span>
</label>
<div className="relative">
<input
type="number"
value={amount0}
onChange={(e) => setAmount0(e.target.value)}
placeholder={`${minDeposit0.toFixed(asset0Decimals === 6 ? 2 : 4)} or more`}
min={minDeposit0}
step={minDeposit0 < 1 ? minDeposit0 : 0.01}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white focus:outline-none focus:border-blue-500"
disabled={isLoading}
/>
<div className="absolute right-3 top-3 flex items-center gap-2">
<span className="text-gray-400 text-sm">{asset0Name}</span>
</div>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Balance: {balance0.toLocaleString()}</span>
<button
onClick={() => setAmount0(balance0.toString())}
className="text-blue-400 hover:text-blue-300"
>
Max
</button>
</div>
</div>
<div className="flex justify-center">
<Plus className="w-5 h-5 text-gray-400" />
</div>
{/* Asset 1 Input */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
{asset1Name} Amount {!isPoolEmpty && '(Auto-calculated)'}
{isPoolEmpty && (
<>
<span className="text-yellow-400 text-xs ml-2"> You set the initial ratio</span>
<span className="text-xs text-gray-500 ml-2">(min: {minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)})</span>
</>
)}
</label>
<div className="relative">
<input
type="number"
value={amount1}
onChange={(e) => setAmount1(e.target.value)}
placeholder={isPoolEmpty ? `${minDeposit1.toFixed(asset1Decimals === 6 ? 2 : 4)} or more` : "Auto-calculated"}
min={isPoolEmpty ? minDeposit1 : undefined}
step={isPoolEmpty ? (minDeposit1 < 1 ? minDeposit1 : 0.01) : undefined}
className={`w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 focus:outline-none ${
isPoolEmpty
? 'text-white focus:border-blue-500'
: 'text-gray-400 cursor-not-allowed'
}`}
disabled={!isPoolEmpty || isLoading}
readOnly={!isPoolEmpty}
/>
<div className="absolute right-3 top-3 flex items-center gap-2">
<span className="text-gray-400 text-sm">{asset1Name}</span>
</div>
</div>
<div className="flex justify-between mt-1 text-xs text-gray-400">
<span>Balance: {balance1.toLocaleString()}</span>
{isPoolEmpty ? (
<button
onClick={() => setAmount1(balance1.toString())}
className="text-blue-400 hover:text-blue-300"
>
Max
</button>
) : (
currentPrice && <span>Rate: 1 {asset0Name} = {currentPrice.toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}</span>
)}
</div>
</div>
{/* Price Info */}
{amount0 && amount1 && (
<div className="bg-gray-800 rounded-lg p-3 space-y-2 text-sm">
{isPoolEmpty && (
<div className="flex justify-between text-yellow-300">
<span>Initial Price</span>
<span>
1 {asset0Name} = {(parseFloat(amount1) / parseFloat(amount0)).toFixed(asset1Decimals === 6 ? 2 : 4)} {asset1Name}
</span>
</div>
)}
<div className="flex justify-between text-gray-300">
<span>Share of Pool</span>
<span>{isPoolEmpty ? '100%' : '~0.1%'}</span>
</div>
<div className="flex justify-between text-gray-300">
<span>Slippage Tolerance</span>
<span>10%</span>
</div>
</div>
)}
<Button
onClick={handleAddLiquidity}
disabled={
isLoading ||
!amount0 ||
!amount1 ||
parseFloat(amount0) > balance0 ||
parseFloat(amount1) > balance1
}
className="w-full bg-gradient-to-r from-green-600 to-blue-600 hover:from-green-700 hover:to-blue-700 h-12"
>
{isLoading ? 'Adding Liquidity...' : 'Add Liquidity'}
</Button>
</div>
</div>
</div>
);
};