feat: add HEZ staking for Trust Score

- Create HEZStakingModal with bond/nominate/unbond tabs
- Add staking selector to choose between HEZ and LP staking
- HEZ staking uses Relay Chain staking pallet
- LP staking uses Asset Hub assetRewards pallet
This commit is contained in:
2026-02-07 02:32:34 +03:00
parent b641c742cc
commit 0d9b8fee4f
4 changed files with 680 additions and 7 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pezkuwi-telegram-miniapp",
"version": "1.0.129",
"version": "1.0.130",
"type": "module",
"description": "Pezkuwichain Telegram Mini App - Forum, Announcements, Rewards",
"author": "Pezkuwichain Team",
+616
View File
@@ -0,0 +1,616 @@
/**
* HEZ Staking Modal for Telegram Mini App
* Allows users to stake HEZ on Relay Chain for Trust Score
*/
import { useState, useEffect, useCallback } from 'react';
import { X, Lock, Unlock, Users, AlertCircle, Loader2, Shield, TrendingUp } from 'lucide-react';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { cn } from '@/lib/utils';
interface StakingInfo {
stash: string;
totalBonded: bigint;
active: bigint;
unlocking: { value: bigint; era: number }[];
nominations: string[];
rewardDestination: string;
}
interface ValidatorInfo {
address: string;
commission: number;
blocked: boolean;
identity?: string;
}
interface HEZStakingModalProps {
isOpen: boolean;
onClose: () => void;
}
const UNITS = 1_000_000_000_000; // 10^12
export function HEZStakingModal({ isOpen, onClose }: HEZStakingModalProps) {
const { api, keypair, address, balance } = useWallet();
const { hapticImpact, hapticNotification, showAlert } = useTelegram();
const [stakingInfo, setStakingInfo] = useState<StakingInfo | null>(null);
const [validators, setValidators] = useState<ValidatorInfo[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isProcessing, setIsProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'status' | 'bond' | 'nominate' | 'unbond'>('status');
const [bondAmount, setBondAmount] = useState('');
const [unbondAmount, setUnbondAmount] = useState('');
const [selectedValidators, setSelectedValidators] = useState<string[]>([]);
// Fetch staking info
const fetchStakingInfo = useCallback(async () => {
if (!api || !address) return;
setIsLoading(true);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stakingPallet = api.query.staking as any;
if (!stakingPallet) {
setError('Staking palleti bulunamadı');
setIsLoading(false);
return;
}
// Check if user has bonded
const ledger = await stakingPallet.ledger(address);
if (ledger && !ledger.isEmpty && !ledger.isNone) {
const ledgerData = ledger.isSome ? ledger.unwrap().toJSON() : ledger.toJSON();
// Get nominations
const nominations = await stakingPallet.nominators(address);
const nominationsData = nominations?.toJSON() as { targets?: string[] } | null;
// Get payee
const payee = await stakingPallet.payee(address);
const payeeStr = payee?.toString() || 'Staked';
setStakingInfo({
stash: ledgerData.stash || address,
totalBonded: BigInt(ledgerData.total || 0),
active: BigInt(ledgerData.active || 0),
unlocking: (ledgerData.unlocking || []).map(
(u: { value: string | number; era: number }) => ({
value: BigInt(u.value),
era: u.era,
})
),
nominations: nominationsData?.targets || [],
rewardDestination: payeeStr,
});
} else {
setStakingInfo(null);
}
// Fetch validators
const validatorEntries = await stakingPallet.validators.entries();
const validatorList: ValidatorInfo[] = validatorEntries
.slice(0, 20) // Limit to 20 validators
.map(
([key, value]: [
{ args: [{ toString: () => string }] },
{ toJSON: () => { commission?: number; blocked?: boolean } },
]) => {
const addr = key.args[0].toString();
const prefs = value.toJSON();
return {
address: addr,
commission: (prefs.commission || 0) / 10000000, // Convert from Perbill
blocked: prefs.blocked || false,
};
}
)
.filter((v: ValidatorInfo) => !v.blocked);
setValidators(validatorList);
} catch (err) {
console.error('Error fetching staking info:', err);
setError('Staking bilgileri alınamadı');
} finally {
setIsLoading(false);
}
}, [api, address]);
useEffect(() => {
if (isOpen && api && address) {
fetchStakingInfo();
}
}, [isOpen, api, address, fetchStakingInfo]);
const formatHEZ = (amount: bigint): string => {
const hez = Number(amount) / UNITS;
return hez.toLocaleString(undefined, { maximumFractionDigits: 4 });
};
const handleBond = async () => {
if (!api || !keypair || !bondAmount) return;
setIsProcessing(true);
setError(null);
try {
const amountBN = BigInt(Math.floor(parseFloat(bondAmount) * UNITS));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stakingPallet = api.tx.staking as any;
let tx;
if (stakingInfo) {
// Already bonded, add to existing stake
tx = stakingPallet.bondExtra(amountBN.toString());
} else {
// First time bonding - set payee to Staked (compound rewards)
tx = stakingPallet.bond(amountBN.toString(), 'Staked');
}
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({
status,
dispatchError,
}: {
status: { isFinalized: boolean };
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Bond neserketî';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}`;
}
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
hapticNotification('success');
showAlert(`${bondAmount} HEZ stake kirin serketî!`);
setBondAmount('');
fetchStakingInfo();
setActiveTab('status');
} catch (err) {
console.error('Bond error:', err);
setError(err instanceof Error ? err.message : 'Bond neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const handleNominate = async () => {
if (!api || !keypair || selectedValidators.length === 0) return;
setIsProcessing(true);
setError(null);
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (api.tx.staking as any).nominate(selectedValidators);
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({
status,
dispatchError,
}: {
status: { isFinalized: boolean };
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Nominate neserketî';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}`;
}
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
hapticNotification('success');
showAlert(`${selectedValidators.length} validator nominate kirin serketî!`);
fetchStakingInfo();
setActiveTab('status');
} catch (err) {
console.error('Nominate error:', err);
setError(err instanceof Error ? err.message : 'Nominate neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const handleUnbond = async () => {
if (!api || !keypair || !unbondAmount) return;
setIsProcessing(true);
setError(null);
try {
const amountBN = BigInt(Math.floor(parseFloat(unbondAmount) * UNITS));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const tx = (api.tx.staking as any).unbond(amountBN.toString());
await new Promise<void>((resolve, reject) => {
tx.signAndSend(
keypair,
({
status,
dispatchError,
}: {
status: { isFinalized: boolean };
dispatchError?: { isModule: boolean; asModule: unknown; toString: () => string };
}) => {
if (status.isFinalized) {
if (dispatchError) {
let errorMsg = 'Unbond neserketî';
if (dispatchError.isModule) {
const decoded = api.registry.findMetaError(dispatchError.asModule);
errorMsg = `${decoded.section}.${decoded.name}`;
}
reject(new Error(errorMsg));
} else {
resolve();
}
}
}
).catch(reject);
});
hapticNotification('success');
showAlert(`${unbondAmount} HEZ unbond kirin serketî! (28 roj li bendê)`);
setUnbondAmount('');
fetchStakingInfo();
setActiveTab('status');
} catch (err) {
console.error('Unbond error:', err);
setError(err instanceof Error ? err.message : 'Unbond neserketî');
hapticNotification('error');
} finally {
setIsProcessing(false);
}
};
const toggleValidator = (addr: string) => {
hapticImpact('light');
setSelectedValidators((prev) =>
prev.includes(addr) ? prev.filter((v) => v !== addr) : [...prev, addr].slice(0, 16)
);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-end justify-center">
<div className="bg-background w-full max-w-lg rounded-t-3xl max-h-[90vh] overflow-hidden flex flex-col animate-in slide-in-from-bottom duration-300">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-green-400" />
<h2 className="text-lg font-semibold">HEZ Staking</h2>
</div>
<button onClick={onClose} className="p-2 hover:bg-secondary rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4">
{isLoading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-8 h-8 animate-spin text-primary" />
</div>
) : (
<>
{/* Tabs */}
<div className="flex gap-1 bg-secondary/50 rounded-lg p-1 mb-4">
{[
{ id: 'status' as const, label: 'Durum', icon: TrendingUp },
{ id: 'bond' as const, label: 'Bond', icon: Lock },
{ id: 'nominate' as const, label: 'Nominate', icon: Users },
{ id: 'unbond' as const, label: 'Unbond', icon: Unlock },
].map(({ id, label, icon: Icon }) => (
<button
key={id}
onClick={() => {
hapticImpact('light');
setActiveTab(id);
}}
className={cn(
'flex-1 py-2 rounded-md text-xs flex items-center justify-center gap-1 transition-colors',
activeTab === id
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground'
)}
>
<Icon className="w-3 h-3" />
{label}
</button>
))}
</div>
{/* Error */}
{error && (
<div className="bg-red-500/10 border border-red-500/30 rounded-lg p-3 mb-4 text-sm text-red-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" />
{error}
</div>
)}
{/* Status Tab */}
{activeTab === 'status' && (
<div className="space-y-4">
{stakingInfo ? (
<>
<div className="bg-gradient-to-r from-green-500/20 to-emerald-500/20 border border-green-500/30 rounded-xl p-4">
<div className="text-sm text-muted-foreground mb-1">Aktîf Stake</div>
<div className="text-2xl font-bold text-green-400">
{formatHEZ(stakingInfo.active)} HEZ
</div>
</div>
<div className="bg-secondary/50 rounded-xl p-4 space-y-3">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Giştî Bonded</span>
<span>{formatHEZ(stakingInfo.totalBonded)} HEZ</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Nominations</span>
<span>{stakingInfo.nominations.length} validator</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Xelat Armanc</span>
<span>{stakingInfo.rewardDestination}</span>
</div>
{stakingInfo.unlocking.length > 0 && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Unbonding</span>
<span className="text-yellow-400">
{stakingInfo.unlocking.length} chunk
</span>
</div>
)}
</div>
{stakingInfo.nominations.length > 0 && (
<div className="bg-secondary/50 rounded-xl p-4">
<div className="text-sm text-muted-foreground mb-2">
Nominated Validators
</div>
<div className="space-y-1 max-h-32 overflow-y-auto">
{stakingInfo.nominations.map((addr, i) => (
<div key={i} className="text-xs font-mono text-muted-foreground">
{addr.slice(0, 8)}...{addr.slice(-8)}
</div>
))}
</div>
</div>
)}
<div className="p-3 bg-blue-500/10 border border-blue-500/30 rounded-lg">
<p className="text-blue-400 text-xs">
💡 HEZ stake kirin Trust Score zêde dike. Herî kêm 1 meh stake bikî ji bo
bonusê.
</p>
</div>
</>
) : (
<div className="text-center py-8">
<Shield className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">Hîn Stake Nekiriye</h3>
<p className="text-muted-foreground text-sm mb-4">
HEZ stake bike ji bo Trust Score qezenckirin
</p>
<button
onClick={() => setActiveTab('bond')}
className="px-6 py-2 bg-green-600 text-white rounded-lg text-sm"
>
Dest Bike
</button>
</div>
)}
</div>
)}
{/* Bond Tab */}
{activeTab === 'bond' && (
<div className="space-y-4">
<div className="bg-secondary/50 rounded-xl p-4">
<div className="flex justify-between text-sm mb-2">
<span className="text-muted-foreground">Bakiyê Te</span>
<span>{balance || '0'} HEZ</span>
</div>
{stakingInfo && (
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Niha Staked</span>
<span className="text-green-400">{formatHEZ(stakingInfo.active)} HEZ</span>
</div>
)}
</div>
<div>
<label className="text-sm text-muted-foreground mb-2 block">Mîqdar (HEZ)</label>
<div className="relative">
<input
type="number"
value={bondAmount}
onChange={(e) => setBondAmount(e.target.value)}
placeholder="0.0000"
className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg"
/>
<button
onClick={() => setBondAmount(balance || '0')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-primary"
>
MAX
</button>
</div>
</div>
<div className="p-3 bg-yellow-500/10 border border-yellow-500/30 rounded-lg">
<p className="text-yellow-400 text-xs">
Stake kirinê paşê 28 roj li bendê ye ji bo vekişandinê.
</p>
</div>
<button
onClick={handleBond}
disabled={isProcessing || !bondAmount || parseFloat(bondAmount) <= 0}
className="w-full py-4 bg-green-600 text-white rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Lock className="w-5 h-5" />
)}
{isProcessing ? 'Tê bond kirin...' : stakingInfo ? 'Zêde Bike' : 'Bond Bike'}
</button>
</div>
)}
{/* Nominate Tab */}
{activeTab === 'nominate' && (
<div className="space-y-4">
{!stakingInfo ? (
<div className="text-center py-8">
<AlertCircle className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<p className="text-muted-foreground">
Pêşî HEZ bond bike, paşê nominate bike
</p>
</div>
) : (
<>
<div className="text-sm text-muted-foreground mb-2">
Validator hilbijêre (max 16): {selectedValidators.length}/16
</div>
<div className="space-y-2 max-h-64 overflow-y-auto">
{validators.map((v) => (
<button
key={v.address}
onClick={() => toggleValidator(v.address)}
className={cn(
'w-full p-3 rounded-xl border text-left transition-all',
selectedValidators.includes(v.address)
? 'border-green-500 bg-green-500/10'
: 'border-border bg-secondary/50'
)}
>
<div className="font-mono text-xs truncate">
{v.address.slice(0, 16)}...{v.address.slice(-8)}
</div>
<div className="text-xs text-muted-foreground mt-1">
Komîsyon: {v.commission.toFixed(2)}%
</div>
</button>
))}
</div>
<button
onClick={handleNominate}
disabled={isProcessing || selectedValidators.length === 0}
className="w-full py-4 bg-blue-600 text-white rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Users className="w-5 h-5" />
)}
{isProcessing ? 'Tê nominate kirin...' : 'Nominate Bike'}
</button>
</>
)}
</div>
)}
{/* Unbond Tab */}
{activeTab === 'unbond' && (
<div className="space-y-4">
{!stakingInfo ? (
<div className="text-center py-8">
<AlertCircle className="w-12 h-12 text-yellow-400 mx-auto mb-4" />
<p className="text-muted-foreground">Tu hîn stake nekiriye</p>
</div>
) : (
<>
<div className="bg-secondary/50 rounded-xl p-4">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Aktîf Stake</span>
<span className="text-green-400">
{formatHEZ(stakingInfo.active)} HEZ
</span>
</div>
</div>
<div>
<label className="text-sm text-muted-foreground mb-2 block">
Mîqdar (HEZ)
</label>
<div className="relative">
<input
type="number"
value={unbondAmount}
onChange={(e) => setUnbondAmount(e.target.value)}
placeholder="0.0000"
className="w-full bg-secondary border border-border rounded-xl p-4 pr-20 text-lg"
/>
<button
onClick={() => setUnbondAmount(formatHEZ(stakingInfo.active))}
className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-primary"
>
MAX
</button>
</div>
</div>
<div className="p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg">
<p className="text-orange-400 text-xs">
Unbond kirin 28 roj digire. Paşê dikare vekişîne.
</p>
</div>
<button
onClick={handleUnbond}
disabled={isProcessing || !unbondAmount || parseFloat(unbondAmount) <= 0}
className="w-full py-4 bg-orange-500 text-white rounded-xl font-medium disabled:opacity-50 flex items-center justify-center gap-2"
>
{isProcessing ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<Unlock className="w-5 h-5" />
)}
{isProcessing ? 'Tê unbond kirin...' : 'Unbond Bike'}
</button>
</>
)}
</div>
)}
</>
)}
</div>
</div>
</div>
);
}
+60 -3
View File
@@ -25,6 +25,7 @@ import { TokensCard } from './TokensCard';
import { SwapModal } from './SwapModal';
import { PoolsModal } from './PoolsModal';
import { LPStakingModal } from './LPStakingModal';
import { HEZStakingModal } from './HEZStakingModal';
import { useWallet } from '@/contexts/WalletContext';
import { useTelegram } from '@/hooks/useTelegram';
import { formatAddress } from '@/lib/wallet-service';
@@ -58,7 +59,9 @@ export function WalletDashboard({ onDisconnect }: Props) {
const [isLoadingTxs, setIsLoadingTxs] = useState(false);
const [isSwapModalOpen, setIsSwapModalOpen] = useState(false);
const [isPoolsModalOpen, setIsPoolsModalOpen] = useState(false);
const [isStakingModalOpen, setIsStakingModalOpen] = useState(false);
const [isLPStakingModalOpen, setIsLPStakingModalOpen] = useState(false);
const [isHEZStakingModalOpen, setIsHEZStakingModalOpen] = useState(false);
const [isStakingSelectorOpen, setIsStakingSelectorOpen] = useState(false);
// Subscribe to PEZ balance (Asset ID: 1) - Uses Asset Hub API
useEffect(() => {
@@ -608,7 +611,7 @@ export function WalletDashboard({ onDisconnect }: Props) {
<button
onClick={() => {
hapticImpact('light');
setIsStakingModalOpen(true);
setIsStakingSelectorOpen(true);
}}
className="flex flex-col items-center gap-1 p-3 bg-gradient-to-r from-yellow-600/20 to-orange-500/20 border border-yellow-500/30 rounded-xl"
>
@@ -724,7 +727,61 @@ export function WalletDashboard({ onDisconnect }: Props) {
{/* Modals */}
<SwapModal isOpen={isSwapModalOpen} onClose={() => setIsSwapModalOpen(false)} />
<PoolsModal isOpen={isPoolsModalOpen} onClose={() => setIsPoolsModalOpen(false)} />
<LPStakingModal isOpen={isStakingModalOpen} onClose={() => setIsStakingModalOpen(false)} />
<LPStakingModal
isOpen={isLPStakingModalOpen}
onClose={() => setIsLPStakingModalOpen(false)}
/>
<HEZStakingModal
isOpen={isHEZStakingModalOpen}
onClose={() => setIsHEZStakingModalOpen(false)}
/>
{/* Staking Selector */}
{isStakingSelectorOpen && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-end justify-center">
<div className="bg-background w-full max-w-lg rounded-t-3xl p-6 animate-in slide-in-from-bottom duration-300">
<h2 className="text-lg font-semibold mb-4 text-center">Staking Hilbijêre</h2>
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => {
setIsStakingSelectorOpen(false);
setIsHEZStakingModalOpen(true);
}}
className="p-4 bg-gradient-to-br from-green-600/20 to-emerald-500/20 border border-green-500/30 rounded-xl"
>
<div className="w-12 h-12 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<Coins className="w-6 h-6 text-green-400" />
</div>
<div className="font-medium">HEZ Staking</div>
<div className="text-xs text-muted-foreground mt-1">Validator nominate bike</div>
<div className="text-xs text-green-400 mt-2">Trust Score +</div>
</button>
<button
onClick={() => {
setIsStakingSelectorOpen(false);
setIsLPStakingModalOpen(true);
}}
className="p-4 bg-gradient-to-br from-purple-600/20 to-pink-500/20 border border-purple-500/30 rounded-xl"
>
<div className="w-12 h-12 bg-purple-500/20 rounded-full flex items-center justify-center mx-auto mb-3">
<Droplets className="w-6 h-6 text-purple-400" />
</div>
<div className="font-medium">LP Staking</div>
<div className="text-xs text-muted-foreground mt-1">LP token stake bike</div>
<div className="text-xs text-purple-400 mt-2">PEZ Xelat +</div>
</button>
</div>
<button
onClick={() => setIsStakingSelectorOpen(false)}
className="w-full mt-4 py-3 bg-secondary text-muted-foreground rounded-xl"
>
Paşve
</button>
</div>
</div>
)}
</div>
);
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "1.0.129",
"buildTime": "2026-02-06T23:26:35.940Z",
"buildNumber": 1770420395941
"version": "1.0.130",
"buildTime": "2026-02-06T23:32:34.947Z",
"buildNumber": 1770420754948
}