From 02094a36359ee224782f2516ec57e76e97af6123 Mon Sep 17 00:00:00 2001 From: Kurdistan Tech Ministry Date: Wed, 4 Feb 2026 11:35:25 +0300 Subject: [PATCH] feat: add XCM teleport and CI/CD deployment workflow Features: - Add XCMTeleportModal for cross-chain HEZ transfers - Support Asset Hub and People Chain teleports - Add "Fund Fees" button with user-friendly tooltips - Use correct XCM V3 format with teyrchain junction Fixes: - Fix PEZ transfer to use Asset Hub API - Silence unnecessary pallet availability warnings - Fix transaction loading performance (10 blocks limit) - Remove Supabase admin_roles dependency CI/CD: - Add auto-deploy to VPS on main branch push - Add version bumping on deploy - Upload build artifacts for deployment --- .github/workflows/quality-gate.yml | 78 ++- shared/images/Nova_GitHub.png:Zone.Identifier | Bin 25 -> 0 bytes shared/images/Pezkuwi.png:Zone.Identifier | Bin 25 -> 0 bytes shared/lib/citizenship-workflow.ts | 6 +- shared/lib/guards.ts | 8 +- shared/lib/referral.ts | 53 +- shared/lib/scores.ts | 4 +- shared/lib/tiki.ts | 4 +- shared/lib/xcm-wizard.ts | 218 ++++++++ web/package.json | 2 +- web/src/components/AccountBalance.tsx | 178 +++++-- web/src/components/AppLayout.tsx | 21 +- web/src/components/TransferModal.tsx | 27 +- web/src/components/XCMTeleportModal.tsx | 468 ++++++++++++++++++ web/src/contexts/AuthContext.tsx | 19 +- web/src/contexts/PezkuwiContext.tsx | 58 ++- web/src/contexts/WebSocketContext.tsx | 9 +- web/src/pages/WalletDashboard.tsx | 9 +- 18 files changed, 1049 insertions(+), 113 deletions(-) delete mode 100644 shared/images/Nova_GitHub.png:Zone.Identifier delete mode 100644 shared/images/Pezkuwi.png:Zone.Identifier create mode 100644 web/src/components/XCMTeleportModal.tsx diff --git a/.github/workflows/quality-gate.yml b/.github/workflows/quality-gate.yml index 1801a589..8c471edf 100644 --- a/.github/workflows/quality-gate.yml +++ b/.github/workflows/quality-gate.yml @@ -1,4 +1,4 @@ -name: Quality Gate +name: Quality Gate & Deploy on: push: @@ -7,9 +7,17 @@ on: branches: [ main, develop ] workflow_dispatch: +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + VITE_SUPABASE_URL: ${{ secrets.VITE_SUPABASE_URL }} + VITE_SUPABASE_ANON_KEY: ${{ secrets.VITE_SUPABASE_ANON_KEY }} + jobs: # ======================================== - # WEB APP - BUILD, LINT & TEST + # WEB APP - LINT, TEST & BUILD # ======================================== web: name: Web App @@ -54,6 +62,12 @@ jobs: working-directory: ./web run: npm run build + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: web-dist + path: web/dist/ + # ======================================== # MOBILE APP - LINT & TEST # ======================================== @@ -91,13 +105,61 @@ jobs: run: npm run test # ======================================== - # SDK UI - BUILD & TEST (SKIPPED - uses root workspace) + # DEPLOY WEB APP TO VPS # ======================================== - # sdk-ui: - # name: SDK UI - # runs-on: ubuntu-latest - # # SDK UI requires the root yarn workspace, skipping for now - # if: false + deploy: + name: Deploy Web + runs-on: ubuntu-latest + needs: [web, mobile] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Configure Git + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + - name: Bump version + working-directory: ./web + run: | + npm version patch --no-git-tag-version + VERSION=$(node -p "require('./package.json').version") + echo "NEW_VERSION=$VERSION" >> $GITHUB_ENV + cd .. + git add web/package.json + git commit -m "chore(web): bump version to $VERSION [skip ci]" || echo "No version change" + git push || echo "Nothing to push" + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: web-dist + path: dist/ + + - name: Deploy to VPS + uses: appleboy/scp-action@v1.0.0 + with: + host: ${{ secrets.VPS_HOST }} + username: ${{ secrets.VPS_USER }} + key: ${{ secrets.VPS_SSH_KEY }} + source: 'dist/*' + target: '/var/www/subdomains/app' + strip_components: 1 + + - name: Post-deploy notification + run: | + echo "✅ Deployed web app v${{ env.NEW_VERSION }} to app.pezkuwichain.io" # ======================================== # SECURITY CHECKS (INFORMATIVE) diff --git a/shared/images/Nova_GitHub.png:Zone.Identifier b/shared/images/Nova_GitHub.png:Zone.Identifier deleted file mode 100644 index d6c1ec682968c796b9f5e9e080cc6f674b57c766..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2xdl#JyUFr831@K2x { try { if (!api?.query?.tiki?.userTikis) { - console.warn('Tiki pallet not available'); + if (import.meta.env.DEV) console.log('Tiki pallet not available on this chain'); return []; } @@ -282,7 +282,7 @@ export async function isStakingScoreTracking( ): Promise { try { if (!api?.query?.stakingScore?.stakingStartBlock) { - console.warn('Staking score pallet not available'); + if (import.meta.env.DEV) console.log('Staking score pallet not available on this chain'); return false; } diff --git a/shared/lib/guards.ts b/shared/lib/guards.ts index 5985fef4..c7cb40ba 100644 --- a/shared/lib/guards.ts +++ b/shared/lib/guards.ts @@ -24,7 +24,7 @@ export async function checkCitizenStatus( try { // Check if Identity KYC pallet exists if (!api.query?.identityKyc?.kycStatuses) { - console.warn('Identity KYC pallet not available'); + if (import.meta.env.DEV) console.log('Identity KYC pallet not available on this chain'); return false; } @@ -61,7 +61,7 @@ export async function checkValidatorStatus( try { // Check if ValidatorPool pallet exists if (!api.query?.validatorPool?.poolMembers) { - console.warn('ValidatorPool pallet not available'); + if (import.meta.env.DEV) console.log('ValidatorPool pallet not available on this chain'); return false; } @@ -142,7 +142,7 @@ export async function checkTikiRole( try { // Check if Tiki pallet exists if (!api.query?.tiki?.userTikis) { - console.warn('Tiki pallet not available'); + if (import.meta.env.DEV) console.log('Tiki pallet not available on this chain'); return false; } @@ -285,7 +285,7 @@ export async function checkStakingScoreTracking( try { if (!api.query?.stakingScore?.stakingStartBlock) { - console.warn('Staking score pallet not available'); + if (import.meta.env.DEV) console.log('Staking score pallet not available on this chain'); return false; } diff --git a/shared/lib/referral.ts b/shared/lib/referral.ts index 78ab0a20..fcdacbc6 100644 --- a/shared/lib/referral.ts +++ b/shared/lib/referral.ts @@ -75,6 +75,13 @@ export async function initiateReferral( }); } +/** + * Check if the referral pallet is available on the chain + */ +function isReferralPalletAvailable(api: ApiPromise): boolean { + return !!(api.query.referral && api.query.referral.pendingReferrals); +} + /** * Get the pending referral for a user (who invited them, if they haven't completed KYC) * @@ -87,6 +94,12 @@ export async function getPendingReferral( address: string ): Promise { try { + // Check if referral pallet exists + if (!isReferralPalletAvailable(api)) { + if (import.meta.env.DEV) console.log('Referral pallet not available on this chain'); + return null; + } + const result = await api.query.referral.pendingReferrals(address); if (result.isEmpty) { @@ -95,7 +108,7 @@ export async function getPendingReferral( return result.toString(); } catch (error) { - console.error('Error fetching pending referral:', error); + if (import.meta.env.DEV) console.error('Error fetching pending referral:', error); return null; } } @@ -112,10 +125,15 @@ export async function getReferralCount( address: string ): Promise { try { + // Check if referral pallet exists + if (!isReferralPalletAvailable(api)) { + return 0; + } + const count = await api.query.referral.referralCount(address); return count.toNumber(); } catch (error) { - console.error('Error fetching referral count:', error); + if (import.meta.env.DEV) console.error('Error fetching referral count:', error); return 0; } } @@ -132,6 +150,11 @@ export async function getReferralInfo( address: string ): Promise { try { + // Check if referral pallet exists + if (!isReferralPalletAvailable(api)) { + return null; + } + const result = await api.query.referral.referrals(address); if (result.isEmpty) { @@ -144,7 +167,7 @@ export async function getReferralInfo( createdAt: parseInt(data.createdAt), }; } catch (error) { - console.error('Error fetching referral info:', error); + if (import.meta.env.DEV) console.error('Error fetching referral info:', error); return null; } } @@ -182,6 +205,16 @@ export async function getReferralStats( api: ApiPromise, address: string ): Promise { + // Check if referral pallet exists first + if (!isReferralPalletAvailable(api)) { + return { + referralCount: 0, + referralScore: 0, + whoInvitedMe: null, + pendingReferral: null, + }; + } + try { const [referralCount, referralInfo, pendingReferral] = await Promise.all([ getReferralCount(api, address), @@ -198,7 +231,7 @@ export async function getReferralStats( pendingReferral, }; } catch (error) { - console.error('Error fetching referral stats:', error); + if (import.meta.env.DEV) console.error('Error fetching referral stats:', error); return { referralCount: 0, referralScore: 0, @@ -221,6 +254,11 @@ export async function getMyReferrals( referrerAddress: string ): Promise { try { + // Check if referral pallet exists + if (!isReferralPalletAvailable(api)) { + return []; + } + const entries = await api.query.referral.referrals.entries(); const myReferrals = entries @@ -237,7 +275,7 @@ export async function getMyReferrals( return myReferrals; } catch (error) { - console.error('Error fetching my referrals:', error); + if (import.meta.env.DEV) console.error('Error fetching my referrals:', error); return []; } } @@ -253,6 +291,11 @@ export async function subscribeToReferralEvents( api: ApiPromise, callback: (event: { type: 'initiated' | 'confirmed'; referrer: string; referred: string; count?: number }) => void ): Promise<() => void> { + // Check if referral pallet exists - if not, return no-op unsubscribe + if (!isReferralPalletAvailable(api)) { + return () => {}; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any const unsub = await api.query.system.events((events: any[]) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/shared/lib/scores.ts b/shared/lib/scores.ts index 34f5eb44..45a8d3f4 100644 --- a/shared/lib/scores.ts +++ b/shared/lib/scores.ts @@ -40,7 +40,7 @@ export async function getTrustScore( ): Promise { try { if (!api?.query?.trust) { - console.warn('Trust pallet not available'); + // Trust pallet not available on this chain - this is expected return 0; } @@ -200,7 +200,7 @@ export async function getStakingScoreFromPallet( ): Promise { try { if (!api?.query?.stakingScore) { - console.warn('Staking score pallet not available'); + // Staking score pallet not available on this chain - this is expected return 0; } diff --git a/shared/lib/tiki.ts b/shared/lib/tiki.ts index 4733c7ab..f4428b65 100644 --- a/shared/lib/tiki.ts +++ b/shared/lib/tiki.ts @@ -217,7 +217,7 @@ export const fetchUserTikis = async ( } if (!api || !api.query.tiki) { - console.warn('Tiki pallet not available on this chain'); + // Tiki pallet not available on this chain - this is expected return []; } @@ -437,7 +437,7 @@ export const fetchUserTikiNFTs = async ( ): Promise => { try { if (!api || !api.query.tiki) { - console.warn('Tiki pallet not available on this chain'); + // Tiki pallet not available on this chain - this is expected return []; } diff --git a/shared/lib/xcm-wizard.ts b/shared/lib/xcm-wizard.ts index 3d63b0df..53e341ea 100644 --- a/shared/lib/xcm-wizard.ts +++ b/shared/lib/xcm-wizard.ts @@ -475,6 +475,224 @@ export async function testXCMTransfer( } } +// ======================================== +// XCM TELEPORT: RELAY CHAIN → ASSET HUB +// ======================================== + +/** + * Teleport HEZ from Relay Chain to Asset Hub + * This is needed to pay fees on Asset Hub for PEZ transfers + * + * @param relayApi - Polkadot.js API instance (connected to relay chain) + * @param amount - Amount in smallest unit (e.g., 100000000000 for 0.1 HEZ with 12 decimals) + * @param account - Account to sign and receive on Asset Hub + * @param assetHubParaId - Asset Hub parachain ID (default: 1000) + * @returns Transaction hash + */ +export async function teleportToAssetHub( + relayApi: ApiPromise, + amount: string | bigint, + account: InjectedAccountWithMeta, + assetHubParaId: number = 1000 +): Promise { + return new Promise(async (resolve, reject) => { + try { + const injector = await (window as any).injectedWeb3[account.meta.source]?.enable?.('PezkuwiChain'); + if (!injector) { + throw new Error('Failed to get injector from wallet extension'); + } + + const signer = injector.signer; + + // Destination: Asset Hub parachain + const dest = { + V3: { + parents: 0, + interior: { + X1: { Parachain: assetHubParaId } + } + } + }; + + // Beneficiary: Same account on Asset Hub + const beneficiary = { + V3: { + parents: 0, + interior: { + X1: { + AccountId32: { + network: null, + id: relayApi.createType('AccountId32', account.address).toHex() + } + } + } + } + }; + + // Assets: Native token (HEZ) + const assets = { + V3: [{ + id: { + Concrete: { + parents: 0, + interior: 'Here' + } + }, + fun: { + Fungible: amount.toString() + } + }] + }; + + // Fee asset item (index 0 = first asset) + const feeAssetItem = 0; + + // Weight limit: Unlimited + const weightLimit = 'Unlimited'; + + // Create teleport transaction + const tx = relayApi.tx.xcmPallet.limitedTeleportAssets( + dest, + beneficiary, + assets, + feeAssetItem, + weightLimit + ); + + let unsub: () => void; + + await tx.signAndSend(account.address, { signer }, ({ status, events, dispatchError }) => { + if (dispatchError) { + if (dispatchError.isModule) { + const decoded = relayApi.registry.findMetaError(dispatchError.asModule); + reject(new Error(`${decoded.section}.${decoded.name}: ${decoded.docs.join(' ')}`)); + } else { + reject(new Error(dispatchError.toString())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock) { + console.log(`✅ XCM Teleport included in block: ${status.asInBlock}`); + + // Check for XCM events + const xcmSent = events.find(({ event }) => + event.section === 'xcmPallet' && event.method === 'Sent' + ); + + if (xcmSent) { + console.log('✅ XCM message sent successfully'); + } + + resolve(status.asInBlock.toString()); + if (unsub) unsub(); + } + }).then(unsubscribe => { unsub = unsubscribe; }); + + } catch (error) { + reject(error); + } + }); +} + +/** + * Teleport HEZ from Asset Hub back to Relay Chain + * + * @param assetHubApi - Polkadot.js API instance (connected to Asset Hub) + * @param amount - Amount in smallest unit + * @param account - Account to sign and receive on relay chain + * @returns Transaction hash + */ +export async function teleportToRelayChain( + assetHubApi: ApiPromise, + amount: string | bigint, + account: InjectedAccountWithMeta +): Promise { + return new Promise(async (resolve, reject) => { + try { + const injector = await (window as any).injectedWeb3[account.meta.source]?.enable?.('PezkuwiChain'); + if (!injector) { + throw new Error('Failed to get injector from wallet extension'); + } + + const signer = injector.signer; + + // Destination: Relay chain (parent) + const dest = { + V3: { + parents: 1, + interior: 'Here' + } + }; + + // Beneficiary: Same account on relay chain + const beneficiary = { + V3: { + parents: 0, + interior: { + X1: { + AccountId32: { + network: null, + id: assetHubApi.createType('AccountId32', account.address).toHex() + } + } + } + } + }; + + // Assets: Native token + const assets = { + V3: [{ + id: { + Concrete: { + parents: 1, + interior: 'Here' + } + }, + fun: { + Fungible: amount.toString() + } + }] + }; + + const feeAssetItem = 0; + const weightLimit = 'Unlimited'; + + const tx = assetHubApi.tx.polkadotXcm.limitedTeleportAssets( + dest, + beneficiary, + assets, + feeAssetItem, + weightLimit + ); + + let unsub: () => void; + + await tx.signAndSend(account.address, { signer }, ({ status, dispatchError }) => { + 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())); + } + if (unsub) unsub(); + return; + } + + if (status.isInBlock) { + resolve(status.asInBlock.toString()); + if (unsub) unsub(); + } + }).then(unsubscribe => { unsub = unsubscribe; }); + + } catch (error) { + reject(error); + } + }); +} + // ======================================== // UTILITY FUNCTIONS // ======================================== diff --git a/web/package.json b/web/package.json index c45bd8c3..d7712cd5 100644 --- a/web/package.json +++ b/web/package.json @@ -1,7 +1,7 @@ { "name": "vite_react_shadcn_ts", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { "predev": "node generate-docs-structure.cjs", diff --git a/web/src/components/AccountBalance.tsx b/web/src/components/AccountBalance.tsx index 8183c32b..7e9abd76 100644 --- a/web/src/components/AccountBalance.tsx +++ b/web/src/components/AccountBalance.tsx @@ -1,11 +1,12 @@ import React, { useEffect, useState } from 'react'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { Wallet, TrendingUp, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users } from 'lucide-react'; +import { Wallet, TrendingUp, ArrowDownRight, RefreshCw, Award, Plus, Coins, Send, Shield, Users, Fuel } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { ASSET_IDS, getAssetSymbol } from '@pezkuwi/lib/wallet'; import { AddTokenModal } from './AddTokenModal'; import { TransferModal } from './TransferModal'; +import { XCMTeleportModal } from './XCMTeleportModal'; import { getAllScores, type UserScores } from '@pezkuwi/lib/scores'; interface TokenBalance { @@ -18,7 +19,7 @@ interface TokenBalance { } export const AccountBalance: React.FC = () => { - const { api, assetHubApi, isApiReady, isAssetHubReady, selectedAccount } = usePezkuwi(); + const { api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount } = usePezkuwi(); const [balance, setBalance] = useState<{ free: string; reserved: string; @@ -28,6 +29,9 @@ export const AccountBalance: React.FC = () => { reserved: '0', total: '0', }); + // HEZ balances on different chains + const [assetHubHezBalance, setAssetHubHezBalance] = useState('0'); + const [peopleHezBalance, setPeopleHezBalance] = useState('0'); const [pezBalance, setPezBalance] = useState('0'); const [usdtBalance, setUsdtBalance] = useState('0'); const [hezUsdPrice, setHezUsdPrice] = useState(0); @@ -44,6 +48,7 @@ export const AccountBalance: React.FC = () => { const [otherTokens, setOtherTokens] = useState([]); const [isAddTokenModalOpen, setIsAddTokenModalOpen] = useState(false); const [isTransferModalOpen, setIsTransferModalOpen] = useState(false); + const [isXCMTeleportModalOpen, setIsXCMTeleportModalOpen] = useState(false); const [selectedTokenForTransfer, setSelectedTokenForTransfer] = useState(null); const [customTokenIds, setCustomTokenIds] = useState(() => { const stored = localStorage.getItem('customTokenIds'); @@ -319,6 +324,36 @@ export const AccountBalance: React.FC = () => { total: totalTokens, }); + // Fetch HEZ balance on Asset Hub (for PEZ transfer fees) + try { + if (assetHubApi && isAssetHubReady) { + const { data: assetHubBalanceData } = await assetHubApi.query.system.account(selectedAccount.address); + const assetHubFree = assetHubBalanceData.free.toString(); + const assetHubHezTokens = (parseInt(assetHubFree) / divisor).toFixed(4); + setAssetHubHezBalance(assetHubHezTokens); + } else { + setAssetHubHezBalance('0.0000'); + } + } catch (error) { + if (import.meta.env.DEV) console.error('Failed to fetch Asset Hub HEZ balance:', error); + setAssetHubHezBalance('0.0000'); + } + + // Fetch HEZ balance on People Chain (for identity/KYC fees) + try { + if (peopleApi && isPeopleReady) { + const { data: peopleBalanceData } = await peopleApi.query.system.account(selectedAccount.address); + const peopleFree = peopleBalanceData.free.toString(); + const peopleHezTokens = (parseInt(peopleFree) / divisor).toFixed(4); + setPeopleHezBalance(peopleHezTokens); + } else { + setPeopleHezBalance('0.0000'); + } + } catch (error) { + if (import.meta.env.DEV) console.error('Failed to fetch People Chain HEZ balance:', error); + setPeopleHezBalance('0.0000'); + } + // Fetch PEZ balance (Asset ID: 1) from Asset Hub try { if (assetHubApi && isAssetHubReady) { @@ -538,59 +573,107 @@ export const AccountBalance: React.FC = () => { return (
- {/* HEZ Balance Card */} + {/* HEZ Balance Card - Multi-Chain */}
HEZ - - HEZ Balance - +
+ + HEZ Balance + +
Multi-Chain Overview
+
+
+
+ +
-
+ {/* Total HEZ */}
- {isLoading ? '...' : balance.total} + {isLoading ? '...' : (parseFloat(balance.total) + parseFloat(assetHubHezBalance) + parseFloat(peopleHezBalance)).toFixed(4)} HEZ
{hezUsdPrice > 0 - ? `≈ $${(parseFloat(balance.total) * hezUsdPrice).toFixed(2)} USD` + ? `≈ $${((parseFloat(balance.total) + parseFloat(assetHubHezBalance) + parseFloat(peopleHezBalance)) * hezUsdPrice).toFixed(2)} USD (Total across all chains)` : 'Price loading...'}
-
-
-
- - Transferable -
-
- {balance.free} HEZ + {/* Chain Balances */} +
+ {/* Relay Chain (Main) */} +
+
+
+
+ Pezkuwi (Relay Chain) +
+
+
{balance.free} HEZ
+
Reserved: {balance.reserved}
+
-
-
- - Reserved + {/* Asset Hub */} +
+
+
+
+ Pezkuwi Asset Hub + (PEZ fees) +
+
+
{assetHubHezBalance} HEZ
+ {parseFloat(assetHubHezBalance) < 0.1 && ( +
⚠️ Low for fees
+ )} +
-
- {balance.reserved} HEZ +
+ + {/* People Chain */} +
+
+
+
+ Pezkuwi People + (Identity fees) +
+
+
{peopleHezBalance} HEZ
+ {parseFloat(peopleHezBalance) < 0.1 && ( +
⚠️ Low for fees
+ )} +
@@ -601,11 +684,26 @@ export const AccountBalance: React.FC = () => { {/* PEZ Balance Card */} -
- PEZ - - PEZ Token Balance - +
+
+ PEZ + + PEZ Balance + +
+
@@ -620,7 +718,7 @@ export const AccountBalance: React.FC = () => { : 'Price loading...'}
- Governance & Rewards Token + Governance & Rewards Token (on Asset Hub)
@@ -848,6 +946,12 @@ export const AccountBalance: React.FC = () => { }} selectedAsset={selectedTokenForTransfer} /> + + {/* XCM Teleport Modal */} + setIsXCMTeleportModalOpen(false)} + />
); }; diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index 9c6800e5..f9b0f108 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -53,25 +53,10 @@ const AppLayout: React.FC = () => { useWallet(); const [, _setIsAdmin] = useState(false); - // Check if user is admin + // Admin status is handled by AuthContext via wallet whitelist + // Supabase admin_roles is optional (table may not exist) React.useEffect(() => { - const checkAdminStatus = async () => { - if (user) { - const { data, error } = await supabase - .from('admin_roles') - .select('role') - .eq('user_id', user.id) - .maybeSingle(); - - if (error) { - if (import.meta.env.DEV) console.warn('Admin check error:', error); - } - _setIsAdmin(!!data); - } else { - _setIsAdmin(false); - } - }; - checkAdminStatus(); + _setIsAdmin(false); // Admin status managed by AuthContext }, [user]); return (
diff --git a/web/src/components/TransferModal.tsx b/web/src/components/TransferModal.tsx index 6622b128..0da0b44f 100644 --- a/web/src/components/TransferModal.tsx +++ b/web/src/components/TransferModal.tsx @@ -66,7 +66,7 @@ const TOKENS: Token[] = [ ]; export const TransferModal: React.FC = ({ isOpen, onClose, selectedAsset }) => { - const { api, isApiReady, selectedAccount } = usePezkuwi(); + const { api, assetHubApi, isApiReady, isAssetHubReady, selectedAccount } = usePezkuwi(); const { toast } = useToast(); const [selectedToken, setSelectedToken] = useState('HEZ'); @@ -97,6 +97,17 @@ export const TransferModal: React.FC = ({ isOpen, onClose, s return; } + // Check if PEZ transfer but Asset Hub not ready + const isPezTransfer = currentToken.symbol === 'PEZ' || currentToken.assetId === 1; + if (isPezTransfer && (!assetHubApi || !isAssetHubReady)) { + toast({ + title: "Error", + description: "Asset Hub connection not ready. PEZ is on Asset Hub.", + variant: "destructive", + }); + return; + } + if (!recipient || !amount) { toast({ title: "Error", @@ -118,14 +129,20 @@ export const TransferModal: React.FC = ({ isOpen, onClose, s const amountInSmallestUnit = BigInt(parseFloat(amount) * Math.pow(10, currentToken.decimals)); let transfer; + let targetApi = api; // Default to main chain API // Create appropriate transfer transaction based on token type - // wHEZ uses native token transfer (balances pallet), all others use assets pallet + // HEZ uses native token transfer (balances pallet on main chain) + // PEZ uses assets pallet on Asset Hub (asset ID: 1) if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) { - // Native HEZ token transfer + // Native HEZ token transfer on main chain transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString()); + } else if (isPezTransfer) { + // PEZ transfer on Asset Hub (asset ID: 1) + targetApi = assetHubApi!; + transfer = assetHubApi!.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString()); } else { - // Asset token transfer (wHEZ, PEZ, wUSDT, etc.) + // Other asset token transfers on main chain transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString()); } @@ -149,7 +166,7 @@ export const TransferModal: React.FC = ({ isOpen, onClose, s let errorMessage = 'Transaction failed'; if (dispatchError.isModule) { - const decoded = api.registry.findMetaError(dispatchError.asModule); + const decoded = targetApi.registry.findMetaError(dispatchError.asModule); errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`; } diff --git a/web/src/components/XCMTeleportModal.tsx b/web/src/components/XCMTeleportModal.tsx new file mode 100644 index 00000000..37a50390 --- /dev/null +++ b/web/src/components/XCMTeleportModal.tsx @@ -0,0 +1,468 @@ +import React, { useState, useEffect } from 'react'; +import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { ArrowDown, Loader2, CheckCircle, XCircle, Info } from 'lucide-react'; +import { useToast } from '@/hooks/use-toast'; + +type TargetChain = 'asset-hub' | 'people'; + +interface ChainInfo { + id: TargetChain; + name: string; + description: string; + teyrchainId: number; + color: string; +} + +const TARGET_CHAINS: ChainInfo[] = [ + { + id: 'asset-hub', + name: 'Pezkuwi Asset Hub', + description: 'For PEZ token transfers', + teyrchainId: 1000, + color: 'blue', + }, + { + id: 'people', + name: 'Pezkuwi People', + description: 'For identity & citizenship', + teyrchainId: 1004, + color: 'purple', + }, +]; + +interface XCMTeleportModalProps { + isOpen: boolean; + onClose: () => void; +} + +export const XCMTeleportModal: React.FC = ({ isOpen, onClose }) => { + const { api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount } = usePezkuwi(); + const { toast } = useToast(); + + const [targetChain, setTargetChain] = useState('asset-hub'); + const [amount, setAmount] = useState(''); + const [isTransferring, setIsTransferring] = useState(false); + const [txStatus, setTxStatus] = useState<'idle' | 'signing' | 'pending' | 'success' | 'error'>('idle'); + const [txHash, setTxHash] = useState(''); + const [relayBalance, setRelayBalance] = useState('0'); + const [assetHubBalance, setAssetHubBalance] = useState('0'); + const [peopleBalance, setPeopleBalance] = useState('0'); + + const selectedChain = TARGET_CHAINS.find(c => c.id === targetChain)!; + + // Fetch balances + useEffect(() => { + const fetchBalances = async () => { + if (!selectedAccount?.address) return; + + // Relay chain balance + if (api && isApiReady) { + try { + const accountInfo = await api.query.system.account(selectedAccount.address); + const free = (accountInfo as any).data.free.toString(); + const balanceNum = Number(free) / 1e12; + setRelayBalance(balanceNum.toFixed(4)); + } catch (err) { + console.error('Error fetching relay balance:', err); + } + } + + // Asset Hub balance + if (assetHubApi && isAssetHubReady) { + try { + const accountInfo = await assetHubApi.query.system.account(selectedAccount.address); + const free = (accountInfo as any).data.free.toString(); + const balanceNum = Number(free) / 1e12; + setAssetHubBalance(balanceNum.toFixed(4)); + } catch (err) { + console.error('Error fetching Asset Hub balance:', err); + } + } + + // People chain balance + if (peopleApi && isPeopleReady) { + try { + const accountInfo = await peopleApi.query.system.account(selectedAccount.address); + const free = (accountInfo as any).data.free.toString(); + const balanceNum = Number(free) / 1e12; + setPeopleBalance(balanceNum.toFixed(4)); + } catch (err) { + console.error('Error fetching People chain balance:', err); + } + } + }; + + if (isOpen) { + fetchBalances(); + } + }, [api, assetHubApi, peopleApi, isApiReady, isAssetHubReady, isPeopleReady, selectedAccount, isOpen]); + + const getTargetBalance = () => { + return targetChain === 'asset-hub' ? assetHubBalance : peopleBalance; + }; + + const handleTeleport = async () => { + if (!api || !isApiReady || !selectedAccount) { + toast({ + title: "Error", + description: "Wallet not connected", + variant: "destructive", + }); + return; + } + + if (!amount || parseFloat(amount) <= 0) { + toast({ + title: "Error", + description: "Please enter a valid amount", + variant: "destructive", + }); + return; + } + + const sendAmount = parseFloat(amount); + const currentBalance = parseFloat(relayBalance); + + if (sendAmount > currentBalance) { + toast({ + title: "Error", + description: "Insufficient balance on Relay Chain", + variant: "destructive", + }); + return; + } + + setIsTransferring(true); + setTxStatus('signing'); + + try { + const { web3FromAddress } = await import('@pezkuwi/extension-dapp'); + const injector = await web3FromAddress(selectedAccount.address); + + // Convert to smallest unit (12 decimals) + const amountInSmallestUnit = BigInt(Math.floor(parseFloat(amount) * 1e12)); + + // Get target teyrchain ID + const targetTeyrchainId = selectedChain.teyrchainId; + + // Destination: Target teyrchain + const dest = { + V3: { + parents: 0, + interior: { + X1: { teyrchain: targetTeyrchainId } + } + } + }; + + // Beneficiary: Same account on target chain + const beneficiary = { + V3: { + parents: 0, + interior: { + X1: { + accountid32: { + network: null, + id: api.createType('AccountId32', selectedAccount.address).toHex() + } + } + } + } + }; + + // Assets: Native token (HEZ) + const assets = { + V3: [{ + id: { + Concrete: { + parents: 0, + interior: 'Here' + } + }, + fun: { + Fungible: amountInSmallestUnit.toString() + } + }] + }; + + // Fee asset ID: Native HEZ token (VersionedAssetId format) + const feeAssetId = { + V3: { + Concrete: { + parents: 0, + interior: 'Here' + } + } + }; + + const weightLimit = 'Unlimited'; + + // Create teleport transaction + const tx = api.tx.xcmPallet.limitedTeleportAssets( + dest, + beneficiary, + assets, + feeAssetId, + weightLimit + ); + + setTxStatus('pending'); + + const unsub = await tx.signAndSend( + selectedAccount.address, + { signer: injector.signer }, + ({ status, events, dispatchError }) => { + if (status.isInBlock) { + if (import.meta.env.DEV) console.log(`XCM Teleport in block: ${status.asInBlock}`); + setTxHash(status.asInBlock.toHex()); + } + + if (status.isFinalized) { + if (dispatchError) { + let errorMessage = 'Teleport failed'; + + if (dispatchError.isModule) { + const decoded = api.registry.findMetaError(dispatchError.asModule); + errorMessage = `${decoded.section}.${decoded.name}: ${decoded.docs}`; + } + + setTxStatus('error'); + toast({ + title: "Teleport Failed", + description: errorMessage, + variant: "destructive", + }); + } else { + setTxStatus('success'); + toast({ + title: "Teleport Successful!", + description: `${amount} HEZ teleported to ${selectedChain.name}!`, + }); + + // Reset after success + setTimeout(() => { + setAmount(''); + setTxStatus('idle'); + setTxHash(''); + onClose(); + }, 3000); + } + + setIsTransferring(false); + unsub(); + } + } + ); + } catch (error) { + console.error('Teleport error:', error); + setTxStatus('error'); + setIsTransferring(false); + + toast({ + title: "Teleport Failed", + description: error instanceof Error ? error.message : "An error occurred", + variant: "destructive", + }); + } + }; + + const handleClose = () => { + if (!isTransferring) { + setAmount(''); + setTxStatus('idle'); + setTxHash(''); + onClose(); + } + }; + + const setQuickAmount = (percent: number) => { + const balance = parseFloat(relayBalance); + if (balance > 0) { + const quickAmount = (balance * percent / 100).toFixed(4); + setAmount(quickAmount); + } + }; + + return ( + + + + + HEZ + Teleport HEZ to Teyrchain + + + Transfer HEZ from Pezkuwi (Relay Chain) to a teyrchain for transaction fees + + + + {txStatus === 'success' ? ( +
+ +

Teleport Successful!

+

{amount} HEZ sent to {selectedChain.name}

+ {txHash && ( +
+
Transaction Hash
+
{txHash}
+
+ )} +
+ ) : txStatus === 'error' ? ( +
+ +

Teleport Failed

+

Please try again

+ +
+ ) : ( +
+ {/* Target Chain Selection */} +
+ + +
+ + {/* Balance Display */} +
+
+
+
+ Pezkuwi (Relay Chain) +
+ {relayBalance} HEZ +
+ +
+ +
+ +
+
+
+ {selectedChain.name} +
+ {getTargetBalance()} HEZ +
+
+ + {/* Info Box */} +
+ +

+ {selectedChain.description}. Teleport at least 0.1 HEZ for fees. +

+
+ + {/* Amount Input */} +
+ + setAmount(e.target.value)} + placeholder="0.1" + className="bg-gray-800 border-gray-700 text-white mt-2" + disabled={isTransferring} + /> + + {/* Quick Amount Buttons */} +
+ {[10, 25, 50, 100].map((percent) => ( + + ))} +
+
+ + {/* Status Messages */} + {txStatus === 'signing' && ( +
+

+ Please sign the transaction in your wallet extension +

+
+ )} + + {txStatus === 'pending' && ( +
+

+ + XCM Teleport in progress... This may take a moment. +

+
+ )} + + {/* Submit Button */} + +
+ )} +
+
+ ); +}; diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 0786d71a..631aa677 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -134,23 +134,10 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children return true; } - // SECONDARY: Check Supabase admin_roles (if wallet not in whitelist) - const { data: { user } } = await supabase.auth.getUser(); - if (user) { - const { data, error } = await supabase - .from('admin_roles') - .select('role') - .eq('user_id', user.id) - .maybeSingle(); + // SECONDARY: Supabase admin_roles check disabled (table may not exist) + // Admin access is primarily wallet-based via the whitelist above - if (!error && data && ['admin', 'super_admin'].includes(data.role)) { - if (import.meta.env.DEV) console.log('✅ Admin access granted (Supabase-based)'); - setIsAdmin(true); - return true; - } - } - - if (import.meta.env.DEV) console.log('❌ Admin access denied'); + if (import.meta.env.DEV) console.log('❌ Admin access denied (wallet not in whitelist)'); setIsAdmin(false); return false; } catch (err) { diff --git a/web/src/contexts/PezkuwiContext.tsx b/web/src/contexts/PezkuwiContext.tsx index 7c4f6ddb..47a2a45f 100644 --- a/web/src/contexts/PezkuwiContext.tsx +++ b/web/src/contexts/PezkuwiContext.tsx @@ -5,14 +5,17 @@ import type { InjectedAccountWithMeta } from '@pezkuwi/extension-inject/types'; import { DEFAULT_ENDPOINT } from '../../../shared/blockchain/pezkuwi'; import { isMobileApp, getNativeWalletAddress, getNativeAccountName } from '@/lib/mobile-bridge'; -// Asset Hub endpoint for PEZ token queries +// Parachain endpoints const ASSET_HUB_ENDPOINT = 'wss://asset-hub-rpc.pezkuwichain.io'; +const PEOPLE_CHAIN_ENDPOINT = 'wss://people-rpc.pezkuwichain.io'; interface PezkuwiContextType { api: ApiPromise | null; assetHubApi: ApiPromise | null; + peopleApi: ApiPromise | null; isApiReady: boolean; isAssetHubReady: boolean; + isPeopleReady: boolean; isConnected: boolean; accounts: InjectedAccountWithMeta[]; selectedAccount: InjectedAccountWithMeta | null; @@ -36,8 +39,10 @@ export const PezkuwiProvider: React.FC = ({ }) => { const [api, setApi] = useState(null); const [assetHubApi, setAssetHubApi] = useState(null); + const [peopleApi, setPeopleApi] = useState(null); const [isApiReady, setIsApiReady] = useState(false); const [isAssetHubReady, setIsAssetHubReady] = useState(false); + const [isPeopleReady, setIsPeopleReady] = useState(false); const [accounts, setAccounts] = useState([]); const [selectedAccount, setSelectedAccount] = useState(null); const [error, setError] = useState(null); @@ -105,6 +110,17 @@ export const PezkuwiProvider: React.FC = ({ if (import.meta.env.DEV) console.log(`📡 Chain: ${chain}`); if (import.meta.env.DEV) console.log(`🖥️ Node: ${nodeName} v${nodeVersion}`); + + // Debug: Check Junction type definition + try { + const junctionType = apiInstance.createType('XcmV3Junction'); + console.log('🔍 XCM Junction type keys:', (junctionType as any).defKeys || Object.keys(junctionType.toJSON() || {})); + // Expose api for console debugging + (window as any).__PEZKUWI_API__ = apiInstance; + console.log('💡 API exposed as window.__PEZKUWI_API__ for debugging'); + } catch (e) { + console.log('⚠️ Could not check Junction type:', e); + } } // Fetch sudo key from blockchain @@ -168,8 +184,43 @@ export const PezkuwiProvider: React.FC = ({ } }; + // Initialize People Chain API for identity/citizenship + const initPeopleApi = async () => { + try { + if (import.meta.env.DEV) { + console.log('🔗 Connecting to People Chain:', PEOPLE_CHAIN_ENDPOINT); + } + + const provider = new WsProvider(PEOPLE_CHAIN_ENDPOINT); + const peopleApiInstance = await ApiPromise.create({ + provider, + signedExtensions: { + AuthorizeCall: { + extrinsic: {}, + payload: {}, + }, + }, + }); + + await peopleApiInstance.isReady; + + setPeopleApi(peopleApiInstance); + setIsPeopleReady(true); + + if (import.meta.env.DEV) { + console.log('✅ Connected to People Chain for identity'); + } + } catch (err) { + if (import.meta.env.DEV) { + console.error('❌ Failed to connect to People Chain:', err); + } + // Don't set error - Identity features just won't work + } + }; + initApi(); initAssetHubApi(); + initPeopleApi(); return () => { if (api) { @@ -178,6 +229,9 @@ export const PezkuwiProvider: React.FC = ({ if (assetHubApi) { assetHubApi.disconnect(); } + if (peopleApi) { + peopleApi.disconnect(); + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [endpoint]); @@ -357,8 +411,10 @@ export const PezkuwiProvider: React.FC = ({ const value: PezkuwiContextType = { api, assetHubApi, + peopleApi, isApiReady, isAssetHubReady, + isPeopleReady, isConnected: isApiReady, // Alias for backward compatibility accounts, selectedAccount, diff --git a/web/src/contexts/WebSocketContext.tsx b/web/src/contexts/WebSocketContext.tsx index 2c5791f9..89d26b10 100644 --- a/web/src/contexts/WebSocketContext.tsx +++ b/web/src/contexts/WebSocketContext.tsx @@ -53,15 +53,10 @@ export const WebSocketProvider: React.FC<{ children: React.ReactNode }> = ({ chi const connectionAttempts = useRef(0); const connect = useCallback((endpointIndex: number = 0) => { - // If we've tried all endpoints, show error once and stop + // If we've tried all endpoints, stop silently (WebSocket is optional) if (endpointIndex >= ENDPOINTS.length) { if (!hasShownFinalError.current) { - if (import.meta.env.DEV) console.error('❌ All WebSocket endpoints failed'); - toast({ - title: "Real-time Connection Unavailable", - description: "Could not connect to WebSocket server. Live updates will be disabled.", - variant: "destructive", - }); + // WebSocket service is optional - fail silently hasShownFinalError.current = true; } return; diff --git a/web/src/pages/WalletDashboard.tsx b/web/src/pages/WalletDashboard.tsx index 606d13a7..e3a0478b 100644 --- a/web/src/pages/WalletDashboard.tsx +++ b/web/src/pages/WalletDashboard.tsx @@ -31,7 +31,7 @@ const WalletDashboard: React.FC = () => { const [recentTransactions, setRecentTransactions] = useState([]); const [isLoadingRecent, setIsLoadingRecent] = useState(false); - // Fetch recent transactions + // Fetch recent transactions (limited to last 10 blocks for performance) const fetchRecentTransactions = async () => { if (!api || !isApiReady || !selectedAccount) return; @@ -41,7 +41,8 @@ const WalletDashboard: React.FC = () => { const currentBlockNumber = currentBlock.block.header.number.toNumber(); const txList: Transaction[] = []; - const blocksToCheck = Math.min(100, currentBlockNumber); + // Only check last 10 blocks for performance (proper indexer needed for full history) + const blocksToCheck = Math.min(10, currentBlockNumber); for (let i = 0; i < blocksToCheck && txList.length < 5; i++) { const blockNumber = currentBlockNumber - i; @@ -303,9 +304,9 @@ const WalletDashboard: React.FC = () => { ) : recentTransactions.length === 0 ? (
-

No recent transactions

+

No recent transactions found

- Your transaction history will appear here + Recent activity from last 10 blocks

) : (