From d81342e94dff3da4fc8e422d673c1486714c6084 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Fri, 6 Feb 2026 14:46:11 +0300 Subject: [PATCH] feat: add LP staking modal and reward pools creation script --- web/scripts/create-staking-pools.mjs | 130 +++++++ web/src/components/LPStakingModal.tsx | 474 ++++++++++++++++++++++++++ web/src/components/PoolDashboard.tsx | 18 +- 3 files changed, 620 insertions(+), 2 deletions(-) create mode 100644 web/scripts/create-staking-pools.mjs create mode 100644 web/src/components/LPStakingModal.tsx diff --git a/web/scripts/create-staking-pools.mjs b/web/scripts/create-staking-pools.mjs new file mode 100644 index 00000000..22a4c50e --- /dev/null +++ b/web/scripts/create-staking-pools.mjs @@ -0,0 +1,130 @@ +/** + * Script to create LP staking reward pools + * Run with: node scripts/create-staking-pools.mjs + * + * Requires MNEMONIC environment variable with admin wallet seed + */ + +import { ApiPromise, WsProvider } from '@pezkuwi/api'; +import { Keyring } from '@pezkuwi/api'; + +const ASSET_HUB_RPC = 'wss://asset-hub-rpc.pezkuwichain.io'; + +// LP Token IDs (from assetConversion pools) +const LP_TOKENS = { + 'HEZ-PEZ': 0, + 'HEZ-USDT': 1, + 'HEZ-DOT': 2, +}; + +// Reward token: PEZ (asset ID 1) +const REWARD_ASSET_ID = 1; + +// Reward rate per block (in smallest units - 12 decimals) +// 0.01 PEZ per block = 10_000_000_000 (10^10) +const REWARD_RATE_PER_BLOCK = '10000000000'; + +// 100 years in blocks (6 second blocks) +const BLOCKS_100_YEARS = 525600000; + +async function main() { + const mnemonic = process.env.MNEMONIC; + if (!mnemonic) { + console.error('ERROR: Set MNEMONIC environment variable'); + console.log('Usage: MNEMONIC="foam hope topic phone year fold lyrics biology erosion feed false island" node scripts/create-staking-pools.mjs'); + process.exit(1); + } + + const provider = new WsProvider(ASSET_HUB_RPC); + const api = await ApiPromise.create({ provider }); + + const keyring = new Keyring({ type: 'sr25519' }); + const admin = keyring.addFromMnemonic(mnemonic); + console.log('Admin address:', admin.address); + + // Get current block for expiry calculation + const header = await api.rpc.chain.getHeader(); + const currentBlock = header.number.toNumber(); + const expiryBlock = currentBlock + BLOCKS_100_YEARS; + console.log('Current block:', currentBlock); + console.log('Expiry block (100 years):', expiryBlock); + + // Format asset location for LP tokens (poolAssets pallet, instance 55) + const formatLpTokenLocation = (lpTokenId) => ({ + parents: 0, + interior: { X2: [{ PalletInstance: 55 }, { GeneralIndex: lpTokenId }] } + }); + + // Format asset location for reward token (assets pallet, instance 50) + const formatRewardTokenLocation = (assetId) => ({ + parents: 0, + interior: { X2: [{ PalletInstance: 50 }, { GeneralIndex: assetId }] } + }); + + // Expiry format: { At: blockNumber } + const expiry = { At: expiryBlock }; + + console.log('\n=== Creating Staking Pools ===\n'); + + for (const [poolName, lpTokenId] of Object.entries(LP_TOKENS)) { + console.log(`Creating pool for ${poolName} (LP Token #${lpTokenId})...`); + + const stakedAssetLocation = formatLpTokenLocation(lpTokenId); + const rewardAssetLocation = formatRewardTokenLocation(REWARD_ASSET_ID); + + try { + const tx = api.tx.assetRewards.createPool( + stakedAssetLocation, + rewardAssetLocation, + REWARD_RATE_PER_BLOCK, + expiry, + admin.address // admin + ); + + await new Promise((resolve, reject) => { + tx.signAndSend(admin, ({ status, events, dispatchError }) => { + if (status.isInBlock) { + console.log(` In block: ${status.asInBlock.toHex()}`); + } else if (status.isFinalized) { + console.log(` Finalized: ${status.asFinalized.toHex()}`); + + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + // Find pool created event + const poolCreated = events.find(({ event }) => + event.section === 'assetRewards' && event.method === 'PoolCreated' + ); + if (poolCreated) { + console.log(` ✅ Pool created:`, poolCreated.event.data.toHuman()); + } + resolve(); + } + } + }); + }); + + console.log(` ✅ ${poolName} staking pool created!\n`); + } catch (err) { + console.error(` ❌ Failed to create ${poolName} pool:`, err.message); + } + } + + // List created pools + console.log('\n=== Created Pools ==='); + const pools = await api.query.assetRewards.pools.entries(); + for (const [key, value] of pools) { + console.log('Pool ID:', key.args[0].toString()); + console.log(' Config:', value.toHuman()); + } + + await api.disconnect(); + console.log('\nDone!'); +} + +main().catch(console.error); diff --git a/web/src/components/LPStakingModal.tsx b/web/src/components/LPStakingModal.tsx new file mode 100644 index 00000000..9eaad76e --- /dev/null +++ b/web/src/components/LPStakingModal.tsx @@ -0,0 +1,474 @@ +import React, { useState, useEffect } from 'react'; +import { X, Lock, Unlock, Gift, AlertCircle, Loader2, Info } from 'lucide-react'; +import { web3FromAddress } from '@pezkuwi/extension-dapp'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { Button } from '@/components/ui/button'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; + +interface StakingPool { + poolId: number; + stakedAsset: string; + rewardAsset: string; + rewardRatePerBlock: string; + totalStaked: string; + userStaked: string; + pendingRewards: string; + lpTokenId: number; + lpBalance: string; +} + +interface LPStakingModalProps { + isOpen: boolean; + onClose: () => void; +} + +const LP_TOKEN_NAMES: Record = { + 0: 'HEZ-PEZ LP', + 1: 'HEZ-USDT LP', + 2: 'HEZ-DOT LP', +}; + +export const LPStakingModal: React.FC = ({ isOpen, onClose }) => { + const { assetHubApi, selectedAccount, isAssetHubReady } = usePezkuwi(); + const [pools, setPools] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isProcessing, setIsProcessing] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(null); + const [selectedPool, setSelectedPool] = useState(null); + const [stakeAmount, setStakeAmount] = useState(''); + const [unstakeAmount, setUnstakeAmount] = useState(''); + const [activeTab, setActiveTab] = useState('stake'); + + // Fetch staking pools + useEffect(() => { + if (!assetHubApi || !isAssetHubReady || !isOpen) return; + + const fetchPools = async () => { + setIsLoading(true); + try { + const poolEntries = await assetHubApi.query.assetRewards.pools.entries(); + const stakingPools: StakingPool[] = []; + + for (const [key, value] of poolEntries) { + const poolId = parseInt(key.args[0].toString()); + const poolData = value.toJSON() as { + stakedAssetId: { interior: { x2: [{ palletInstance: number }, { generalIndex: number }] } }; + rewardAssetId: { interior: { x2: [{ palletInstance: number }, { generalIndex: number }] } }; + rewardRatePerBlock: string; + totalTokensStaked: string; + lastUpdatedBlock: number; + }; + + // Extract LP token ID from staked asset location + const lpTokenId = poolData.stakedAssetId?.interior?.x2?.[1]?.generalIndex ?? 0; + + // Get user's stake if account connected + let userStaked = '0'; + const pendingRewards = '0'; // TODO: Calculate from reward debt + let lpBalance = '0'; + + if (selectedAccount) { + try { + const stakeInfo = await assetHubApi.query.assetRewards.poolStakers([poolId, selectedAccount.address]); + if (stakeInfo && (stakeInfo as { isSome: boolean }).isSome) { + const stakeData = (stakeInfo as { unwrap: () => { toJSON: () => { amount: string; rewardDebt: string } } }).unwrap().toJSON(); + userStaked = stakeData.amount || '0'; + // Pending rewards calculation would need more complex logic + } + + // Get LP token balance from poolAssets + const lpBal = await assetHubApi.query.poolAssets.account(lpTokenId, selectedAccount.address); + if (lpBal && (lpBal as { isSome: boolean }).isSome) { + const lpData = (lpBal as { unwrap: () => { toJSON: () => { balance: string } } }).unwrap().toJSON(); + lpBalance = lpData.balance || '0'; + } + } catch (e) { + console.error('Error fetching user stake:', e); + } + } + + stakingPools.push({ + poolId, + stakedAsset: LP_TOKEN_NAMES[lpTokenId] || `LP Token #${lpTokenId}`, + rewardAsset: 'PEZ', + rewardRatePerBlock: poolData.rewardRatePerBlock || '0', + totalStaked: poolData.totalTokensStaked || '0', + userStaked, + pendingRewards, + lpTokenId, + lpBalance, + }); + } + + setPools(stakingPools); + if (stakingPools.length > 0 && selectedPool === null) { + setSelectedPool(stakingPools[0].poolId); + } + } catch (err) { + console.error('Error fetching staking pools:', err); + setError('Failed to fetch staking pools'); + } finally { + setIsLoading(false); + } + }; + + fetchPools(); + }, [assetHubApi, isAssetHubReady, isOpen, selectedAccount, selectedPool]); + + const formatAmount = (amount: string, decimals: number = 12): string => { + const value = Number(amount) / Math.pow(10, decimals); + return value.toLocaleString(undefined, { maximumFractionDigits: 4 }); + }; + + const handleStake = async () => { + if (!assetHubApi || !selectedAccount || selectedPool === null || !stakeAmount) return; + + setIsProcessing(true); + setError(null); + setSuccess(null); + + try { + const pool = pools.find(p => p.poolId === selectedPool); + if (!pool) throw new Error('Pool not found'); + + const amountBN = BigInt(Math.floor(parseFloat(stakeAmount) * 1e12)); + const injector = await web3FromAddress(selectedAccount.address); + + const tx = assetHubApi.tx.assetRewards.stake(selectedPool, amountBN.toString()); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess(`Successfully staked ${stakeAmount} ${pool.stakedAsset}!`); + setStakeAmount(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to stake'); + } finally { + setIsProcessing(false); + } + }; + + const handleUnstake = async () => { + if (!assetHubApi || !selectedAccount || selectedPool === null || !unstakeAmount) return; + + setIsProcessing(true); + setError(null); + setSuccess(null); + + try { + const pool = pools.find(p => p.poolId === selectedPool); + if (!pool) throw new Error('Pool not found'); + + const amountBN = BigInt(Math.floor(parseFloat(unstakeAmount) * 1e12)); + const injector = await web3FromAddress(selectedAccount.address); + + const tx = assetHubApi.tx.assetRewards.unstake(selectedPool, amountBN.toString()); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess(`Successfully unstaked ${unstakeAmount} ${pool.stakedAsset}!`); + setUnstakeAmount(''); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to unstake'); + } finally { + setIsProcessing(false); + } + }; + + const handleHarvest = async () => { + if (!assetHubApi || !selectedAccount || selectedPool === null) return; + + setIsProcessing(true); + setError(null); + setSuccess(null); + + try { + const injector = await web3FromAddress(selectedAccount.address); + const tx = assetHubApi.tx.assetRewards.harvestRewards(selectedPool); + + await new Promise((resolve, reject) => { + tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, dispatchError }) => { + if (status.isFinalized) { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = assetHubApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + } else { + resolve(); + } + } + } + ); + }); + + setSuccess('Successfully harvested rewards!'); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to harvest rewards'); + } finally { + setIsProcessing(false); + } + }; + + if (!isOpen) return null; + + const currentPool = pools.find(p => p.poolId === selectedPool); + + return ( +
+
+
+

LP Staking

+ +
+ + {error && ( + + + {error} + + )} + + {success && ( + + {success} + + )} + + {isLoading ? ( +
+ +

Loading staking pools...

+
+ ) : pools.length === 0 ? ( + + + + No staking pools found. Admin needs to create them first. + + + ) : ( + <> + {/* Pool Selection */} +
+ + +
+ + {/* Pool Stats */} + {currentPool && ( +
+
+ Total Staked: + {formatAmount(currentPool.totalStaked)} LP +
+
+ Your Staked: + {formatAmount(currentPool.userStaked)} LP +
+
+ Your LP Balance: + {formatAmount(currentPool.lpBalance)} LP +
+
+ Reward Rate: + {formatAmount(currentPool.rewardRatePerBlock)} PEZ/block +
+
+ )} + + {/* Tabs */} + + + + + Stake + + + + Unstake + + + + Harvest + + + + +
+
+ +
+ setStakeAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white" + disabled={isProcessing} + /> + +
+
+ +
+
+ + +
+
+ +
+ setUnstakeAmount(e.target.value)} + placeholder="0.0" + className="w-full bg-gray-800 border border-gray-700 rounded-lg px-4 py-3 text-white" + disabled={isProcessing} + /> + +
+
+ +
+
+ + +
+
+

Pending Rewards

+

+ {currentPool ? formatAmount(currentPool.pendingRewards) : '0'} PEZ +

+
+ +
+
+
+ + )} +
+
+ ); +}; diff --git a/web/src/components/PoolDashboard.tsx b/web/src/components/PoolDashboard.tsx index 6343d643..e3d5183a 100644 --- a/web/src/components/PoolDashboard.tsx +++ b/web/src/components/PoolDashboard.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock } from 'lucide-react'; +import { TrendingUp, Droplet, DollarSign, Percent, Info, AlertTriangle, BarChart3, Clock, Lock } from 'lucide-react'; import { Card } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Alert, AlertDescription } from '@/components/ui/alert'; @@ -11,6 +11,7 @@ import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; import { NATIVE_TOKEN_ID } from '@/types/dex'; import { AddLiquidityModal } from '@/components/AddLiquidityModal'; import { RemoveLiquidityModal } from '@/components/RemoveLiquidityModal'; +import { LPStakingModal } from '@/components/LPStakingModal'; // Helper function to convert asset IDs to user-friendly display names // Users should only see HEZ, PEZ, USDT - wrapped tokens are backend details @@ -51,6 +52,7 @@ const PoolDashboard = () => { const [error, setError] = useState(null); const [isAddLiquidityModalOpen, setIsAddLiquidityModalOpen] = useState(false); const [isRemoveLiquidityModalOpen, setIsRemoveLiquidityModalOpen] = useState(false); + const [isStakingModalOpen, setIsStakingModalOpen] = useState(false); // Pool selection state const [availablePools, setAvailablePools] = useState>([]); @@ -577,13 +579,20 @@ const PoolDashboard = () => { -
+
+
); };