fix(web): correct asset IDs/decimals + chain routing, string-BigInt amounts, presale signer

Fund-logic hardening (audit remediation):
- TransferModal: source asset id/decimals from the canonical MINTABLE_ASSETS
  (wUSDT=1000, wDOT=1001, wETH=1002, wBTC=1003) instead of the wrong hardcoded
  ids (BTC=3 was the deprecated "Old USDT", ETH=4, DOT=5). Route every non-native
  asset through the Asset Hub api; only native HEZ uses the relay balances pallet.
  Removes the latent wrong-asset / wrong-chain transfer.
- Replace BigInt(parseFloat(x)*10**dec) with string-based parseTokenInput in
  TransferModal + 3 DEX init modals; BigInt(float) threw RangeError on common
  fractional amounts (e.g. 100.3 HEZ), breaking sends and pool/bridge init.
- Presale.contribute now resolves a signer via getSigner and passes { signer }
  to signAndSend (was signer-less -> broken for all users).
- presale.ts contribute/refund/claimVested now reject on dropped/invalid/usurped/
  retracted tx states instead of hanging the UI forever.
This commit is contained in:
2026-07-24 22:55:54 -07:00
parent 686eecc09e
commit 1c6ff3d578
8 changed files with 57 additions and 36940 deletions
+12
View File
@@ -445,6 +445,10 @@ export async function contribute(
if (status.isInBlock) {
onStatus?.('Transaction in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Transaction was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Transaction failed';
@@ -489,6 +493,10 @@ export async function refund(
if (status.isInBlock) {
onStatus?.('Refund in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Refund was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Refund failed';
@@ -533,6 +541,10 @@ export async function claimVested(
if (status.isInBlock) {
onStatus?.('Claim in block...');
}
if (status.isDropped || status.isInvalid || status.isUsurped || status.isRetracted) {
resolve({ success: false, error: 'Claim was dropped or invalidated by the network. Please try again.' });
return;
}
if (status.isFinalized) {
if (dispatchError) {
let errorMessage = 'Claim failed';
-36907
View File
File diff suppressed because it is too large Load Diff
+32 -27
View File
@@ -20,6 +20,8 @@ import {
} from '@/components/ui/select';
import { ArrowRight, Loader2, CheckCircle, XCircle } from 'lucide-react';
import { useToast } from '@/hooks/use-toast';
import { MINTABLE_ASSETS } from './dex/mintableAssets';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface TokenBalance {
assetId: number;
@@ -36,7 +38,7 @@ interface TransferModalProps {
selectedAsset?: TokenBalance | null;
}
type TokenType = 'HEZ' | 'PEZ' | 'USDT' | 'BTC' | 'ETH' | 'DOT';
type TokenType = 'HEZ' | 'PEZ' | 'wUSDT' | 'wDOT' | 'wETH' | 'wBTC';
interface Token {
symbol: TokenType;
@@ -51,19 +53,26 @@ const TOKEN_LOGOS: Record<string, string> = {
HEZ: '/tokens/HEZ.png',
PEZ: '/tokens/PEZ.png',
USDT: '/tokens/USDT.png',
wUSDT: '/tokens/USDT.png',
wDOT: '/tokens/DOT.png',
wETH: '/tokens/ETH.png',
wBTC: '/tokens/BTC.png',
BTC: '/tokens/BTC.png',
ETH: '/tokens/ETH.png',
DOT: '/tokens/DOT.png',
BNB: '/tokens/BNB.png',
};
// Wrapped assets (id + decimals) are sourced from the single source of truth
// (MINTABLE_ASSETS in dex/mintableAssets.ts): wUSDT=1000, wDOT=1001, wETH=1002, wBTC=1003.
// All of these live on Asset Hub (assets pallet). Only native HEZ is on the relay chain.
const TOKENS: Token[] = [
{ symbol: 'HEZ', name: 'Hez Token', decimals: 12, color: 'from-green-600 to-yellow-400' },
{ symbol: 'PEZ', name: 'Pez Token', assetId: 1, decimals: 12, color: 'from-blue-600 to-purple-400' },
{ symbol: 'USDT', name: 'Tether USD', assetId: 1000, decimals: 6, color: 'from-green-500 to-green-600' },
{ symbol: 'BTC', name: 'Bitcoin', assetId: 3, decimals: 8, color: 'from-orange-500 to-yellow-500' },
{ symbol: 'ETH', name: 'Ethereum', assetId: 4, decimals: 18, color: 'from-purple-500 to-blue-500' },
{ symbol: 'DOT', name: 'Polkadot', assetId: 5, decimals: 10, color: 'from-pink-500 to-red-500' },
{ symbol: 'wUSDT', name: MINTABLE_ASSETS.wUSDT.name, assetId: MINTABLE_ASSETS.wUSDT.id, decimals: MINTABLE_ASSETS.wUSDT.decimals, color: 'from-green-500 to-green-600' },
{ symbol: 'wDOT', name: MINTABLE_ASSETS.wDOT.name, assetId: MINTABLE_ASSETS.wDOT.id, decimals: MINTABLE_ASSETS.wDOT.decimals, color: 'from-pink-500 to-red-500' },
{ symbol: 'wETH', name: MINTABLE_ASSETS.wETH.name, assetId: MINTABLE_ASSETS.wETH.id, decimals: MINTABLE_ASSETS.wETH.decimals, color: 'from-purple-500 to-blue-500' },
{ symbol: 'wBTC', name: MINTABLE_ASSETS.wBTC.name, assetId: MINTABLE_ASSETS.wBTC.id, decimals: MINTABLE_ASSETS.wBTC.decimals, color: 'from-orange-500 to-yellow-500' },
];
export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, selectedAsset }) => {
@@ -99,14 +108,11 @@ export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, s
return;
}
// Check if Asset Hub transfer (PEZ, wUSDT, wHEZ are on Asset Hub)
const isAssetHubTransfer = currentToken.symbol === 'PEZ' ||
currentToken.symbol === 'USDT' ||
currentToken.symbol === 'wUSDT' ||
currentToken.symbol === 'wHEZ' ||
currentToken.assetId === 1 || // PEZ
currentToken.assetId === 2 || // wHEZ
currentToken.assetId === 1000; // wUSDT
// Native HEZ (no assetId, or assetId 0) transfers on the relay chain.
// Every other token is an Asset Hub assets-pallet asset (PEZ, wHEZ, wUSDT,
// wDOT, wETH, wBTC and any custom token), so it must route through Asset Hub.
const isNativeHez = currentToken.assetId === undefined || currentToken.assetId === 0;
const isAssetHubTransfer = !isNativeHez;
if (isAssetHubTransfer && (!assetHubApi || !isAssetHubReady)) {
toast({
title: t('transfer.error'),
@@ -133,25 +139,24 @@ export const TransferModal: React.FC<TransferModalProps> = ({ isOpen, onClose, s
const { getSigner } = await import('@/lib/get-signer');
const injector = await getSigner(selectedAccount.address, walletSource, isAssetHubTransfer ? assetHubApi : api);
// Convert amount to smallest unit
const amountInSmallestUnit = BigInt(parseFloat(amount) * Math.pow(10, currentToken.decimals));
// Convert amount to smallest unit using string-based BigInt parsing.
// (BigInt(parseFloat(amount) * 10**decimals) throws a RangeError on
// fractional amounts because the intermediate double is non-integer.)
const amountInSmallestUnit = parseTokenInput(amount, currentToken.decimals);
let transfer;
let targetApi = api; // Default to main chain API
// Create appropriate transfer transaction based on token type
// HEZ uses native token transfer (balances pallet on main chain)
// PEZ, wHEZ, wUSDT use assets pallet on Asset Hub
if (currentToken.assetId === undefined || (selectedToken === 'HEZ' && !selectedAsset)) {
// Native HEZ token transfer on main chain
transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit.toString());
} else if (isAssetHubTransfer) {
// Asset Hub transfer (PEZ, wHEZ, wUSDT)
targetApi = assetHubApi!;
transfer = assetHubApi!.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString());
// Create appropriate transfer transaction based on token type.
// HEZ uses native token transfer (balances pallet on the relay chain).
// Every asset (PEZ, wHEZ, wUSDT, wDOT, wETH, wBTC, custom) is on Asset Hub.
if (isNativeHez) {
// Native HEZ token transfer on the relay chain
transfer = api.tx.balances.transferKeepAlive(recipient, amountInSmallestUnit);
} else {
// Other asset token transfers on main chain
transfer = api.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit.toString());
// Asset Hub transfer (assets pallet)
targetApi = assetHubApi!;
transfer = assetHubApi!.tx.assets.transfer(currentToken.assetId, recipient, amountInSmallestUnit);
}
setTxStatus('pending');
@@ -10,6 +10,7 @@ import { Alert, AlertDescription } from '@/components/ui/alert';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface InitializeHezPoolModalProps {
isOpen: boolean;
@@ -131,7 +132,7 @@ export const InitializeHezPoolModal: React.FC<InitializeHezPoolModalProps> = ({
return;
}
const amountRaw = BigInt(parseFloat(amount) * 10 ** 12);
const amountRaw = BigInt(parseTokenInput(amount, 12));
if (amountRaw <= BigInt(0)) {
setErrorMessage(t('common.amountGtZero'));
@@ -9,6 +9,7 @@ import { Input } from '@/components/ui/input';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
interface InitializeUsdtModalProps {
isOpen: boolean;
@@ -86,7 +87,7 @@ export const InitializeUsdtModal: React.FC<InitializeUsdtModalProps> = ({
return;
}
const usdtAmountRaw = BigInt(parseFloat(usdtAmount) * 10 ** USDT_DECIMALS);
const usdtAmountRaw = BigInt(parseTokenInput(usdtAmount, USDT_DECIMALS));
if (usdtAmountRaw <= BigInt(0)) {
setErrorMessage(t('common.amountGtZero'));
@@ -9,6 +9,7 @@ import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { useToast } from '@/hooks/use-toast';
import { useTranslation } from 'react-i18next';
import { parseTokenInput } from '@pezkuwi/utils/dex';
import {
checkBridgeStatus,
fetchAssetHubUsdtInfo,
@@ -160,8 +161,8 @@ export const XCMBridgeSetupModal: React.FC<XCMBridgeSetupModalProps> = ({
try {
// Convert amounts to raw values (6 decimals for wUSDT, 12 for HEZ)
const wusdtRaw = BigInt(parseFloat(wusdtAmount) * 10 ** 6).toString();
const hezRaw = BigInt(parseFloat(hezAmount) * 10 ** 12).toString();
const wusdtRaw = parseTokenInput(wusdtAmount, 6);
const hezRaw = parseTokenInput(hezAmount, 12);
await createWUsdtHezPool(
assetHubApi,
+6 -2
View File
@@ -14,7 +14,7 @@ import { toast } from 'sonner';
export default function Presale() {
const { t } = useTranslation();
const navigate = useNavigate();
const { api, selectedAccount, isApiReady } = usePezkuwi();
const { api, selectedAccount, isApiReady, walletSource } = usePezkuwi();
const { balances } = useWallet();
const [wusdtAmount, setWusdtAmount] = useState('');
@@ -107,9 +107,13 @@ export default function Presale() {
try {
const amountWithDecimals = Math.floor(amount * 1_000_000); // 6 decimals
// Get signer (extension or WalletConnect) — no global api.setSigner exists
const { getSigner } = await import('@/lib/get-signer');
const injector = await getSigner(selectedAccount.address, walletSource, api);
const tx = api.tx.presale.contribute(amountWithDecimals);
await tx.signAndSend(selectedAccount.address, ({ status, events }) => {
await tx.signAndSend(selectedAccount.address, { signer: injector.signer }, ({ status, events }) => {
if (status.isInBlock) {
if (import.meta.env.DEV) {
console.log(`Transaction included in block: ${status.asInBlock}`);