diff --git a/apps/tangle-dapp/src/components/tables/OperatorsTable.tsx b/apps/tangle-dapp/src/components/tables/OperatorsTable.tsx index 5b9e124abe..56d5ae1663 100644 --- a/apps/tangle-dapp/src/components/tables/OperatorsTable.tsx +++ b/apps/tangle-dapp/src/components/tables/OperatorsTable.tsx @@ -9,7 +9,7 @@ import { TableVariant } from '@tangle-network/ui-components/components/Table/typ interface Props { operatorMap: Map | null; isLoading: boolean; - onRestakeClicked: () => void; + onDelegateClicked: (operatorAddress: Address) => void; } const formatDelegationCount = (count: bigint | null | undefined): number => { @@ -20,7 +20,7 @@ const formatDelegationCount = (count: bigint | null | undefined): number => { export const OperatorsTable: FC = ({ operatorMap, isLoading, - onRestakeClicked, + onDelegateClicked, }) => { const data = useMemo(() => { if (!operatorMap) return []; @@ -49,18 +49,16 @@ export const OperatorsTable: FC = ({ emptyTableProps={{ title: 'No Operators Available', description: 'Be the first to register as a restaking operator.', - buttonText: 'Register as Operator', - buttonProps: { onClick: onRestakeClicked }, }} tableProps={{ variant: TableVariant.GLASS_OUTER, }} - RestakeOperatorAction={({ address: _address }) => ( + RestakeOperatorAction={({ address }) => ( ); }} @@ -692,21 +702,21 @@ const RestakeUnstakeForm: FC = () => { - setIsUnstakeRequestTableOpen(false)} + onClose={() => setIsUndelegateRequestTableOpen(false)} onRefresh={refreshAll} /> - setIsUnstakeRequestTableOpen(false)} + onClose={() => setIsUndelegateRequestTableOpen(false)} onRefresh={refreshAll} className="md:hidden" /> @@ -716,9 +726,9 @@ const RestakeUnstakeForm: FC = () => { isOpen={isDelegationModalOpen} setIsOpen={setIsDelegationModalOpen} titleWhenEmpty="No Delegations Found" - descriptionWhenEmpty="You don't have any active delegations to unstake." + descriptionWhenEmpty="You don't have any active delegations to undelegate." items={delegationItems} - searchInputId="restake-unstake-delegation-search" + searchInputId="restake-undelegate-delegation-search" searchPlaceholder="Search delegations..." getItemKey={(item) => item.id} onSelect={handleDelegationSelect} @@ -767,11 +777,11 @@ const RestakeUnstakeForm: FC = () => { ); }; -export default RestakeUnstakeForm; +export default RestakeUndelegateForm; -// Unstake requests view component -type UnstakeRequestsViewProps = { - unstakeRequests: UnstakeRequest[]; +// Undelegate requests view component +type UndelegateRequestsViewProps = { + undelegateRequests: UndelegateRequest[]; tokenMetadatas: | Array<{ id: EvmAddress; symbol: string; decimals: number }> | undefined; @@ -780,8 +790,8 @@ type UnstakeRequestsViewProps = { className?: string; }; -const UnstakeRequestsView: FC = ({ - unstakeRequests, +const UndelegateRequestsView: FC = ({ + undelegateRequests, tokenMetadatas, onClose, onRefresh, @@ -789,36 +799,36 @@ const UnstakeRequestsView: FC = ({ }) => { const { data: config } = useProtocolConfig(); const currentRound = config?.currentRound ?? BigInt(0); - const readyCount = unstakeRequests.filter( + const readyCount = undelegateRequests.filter( (r) => r.readyAtRound <= currentRound, ).length; - const { execute: executeUnstake, status: executeStatus } = - useExecuteUnstakeTx(); + const { execute: executeUndelegate, status: executeStatus } = + useExecuteUndelegateTx(); const isExecuting = executeStatus === TxStatus.PROCESSING; return ( -
+
0 - ? 'Unstake Requests' - : 'No Unstake Requests' + undelegateRequests.length > 0 + ? 'Undelegate Requests' + : 'No Undelegate Requests' } />
- {unstakeRequests.length > 0 && ( + {undelegateRequests.length > 0 && (
- {unstakeRequests.length > 0 ? ( + {undelegateRequests.length > 0 ? (
- {unstakeRequests.map((request) => { + {undelegateRequests.map((request) => { const metadata = tokenMetadatas?.find( (m) => m.id.toLowerCase() === request.token.toLowerCase(), ); @@ -877,8 +887,8 @@ const UnstakeRequestsView: FC = ({ variant="body1" className="text-mono-120 dark:text-mono-100" > - Your requests will appear here after scheduling an unstake. Requests - can be executed after the waiting period. + Your requests will appear here after scheduling an undelegate. + Requests can be executed after the waiting period. )} diff --git a/apps/tangle-dapp/src/pages/restake/withdraw/index.tsx b/apps/tangle-dapp/src/pages/restake/withdraw/index.tsx index d311d4a293..4aa7cd6e6d 100644 --- a/apps/tangle-dapp/src/pages/restake/withdraw/index.tsx +++ b/apps/tangle-dapp/src/pages/restake/withdraw/index.tsx @@ -346,37 +346,41 @@ const RestakeWithdrawForm: FC = () => { }, }); - // Get pending withdraw requests (prefer indexer, but fall back to on-chain when it lags) + // Get pending withdraw requests (prefer on-chain data as source of truth, fall back to indexer) const withdrawRequests = useMemo(() => { - const fromIndexer = - delegator?.withdrawRequests.filter((r) => r.status === 'PENDING') ?? []; - if (fromIndexer.length > 0) { - return fromIndexer; + // On-chain data is the source of truth - use it when available + if ( + Array.isArray(onChainPendingWithdrawals) && + onChainPendingWithdrawals.length > 0 + ) { + const delayRounds = protocolConfig?.leaveDelegatorsDelay ?? BigInt(0); + return ( + onChainPendingWithdrawals as Array<{ + asset: { kind: number; token: Address }; + amount: bigint; + requestedRound: bigint; + }> + ).map((r, idx) => ({ + id: `${r.asset.token.toLowerCase()}-${r.requestedRound.toString()}-${idx}`, + token: r.asset.token, + nonce: BigInt(0), + amount: r.amount, + requestedRound: r.requestedRound, + readyAtRound: r.requestedRound + delayRounds, + status: 'PENDING' as const, + executedAt: null, + })) satisfies WithdrawRequest[]; } - if (!onChainPendingWithdrawals) { + // If on-chain returns empty array, it means no pending requests (source of truth) + if (Array.isArray(onChainPendingWithdrawals)) { return []; } - const delayRounds = protocolConfig?.leaveDelegatorsDelay ?? BigInt(0); - const requests = ( - onChainPendingWithdrawals as Array<{ - asset: { kind: number; token: Address }; - amount: bigint; - requestedRound: bigint; - }> - ).map((r, idx) => ({ - id: `${r.asset.token.toLowerCase()}-${r.requestedRound.toString()}-${idx}`, - token: r.asset.token, - nonce: BigInt(0), - amount: r.amount, - requestedRound: r.requestedRound, - readyAtRound: r.requestedRound + delayRounds, - status: 'PENDING' as const, - executedAt: null, - })) satisfies WithdrawRequest[]; - - return requests; + // Fall back to indexer only when on-chain data is not yet available + return ( + delegator?.withdrawRequests.filter((r) => r.status === 'PENDING') ?? [] + ); }, [ delegator?.withdrawRequests, onChainPendingWithdrawals, @@ -546,7 +550,8 @@ const RestakeWithdrawForm: FC = () => { {!isWithdrawRequestTableOpen && ( setIsWithdrawRequestTableOpen(true)} /> )} @@ -777,7 +782,7 @@ const WithdrawRequestView: FC = ({ return ( -
+
0 diff --git a/apps/tangle-dapp/src/types/restake.ts b/apps/tangle-dapp/src/types/restake.ts index 232f2b76c2..1fcba73ae8 100644 --- a/apps/tangle-dapp/src/types/restake.ts +++ b/apps/tangle-dapp/src/types/restake.ts @@ -58,7 +58,7 @@ export type EvmDelegationFormFields = { assetId: Address; }; -export type EvmUnstakeFormFields = EvmDelegationFormFields; +export type EvmUndelegateFormFields = EvmDelegationFormFields; export type EvmWithdrawFormFields = { amount: string; diff --git a/libs/tangle-shared-ui/src/context/RestakeContext.tsx b/libs/tangle-shared-ui/src/context/RestakeContext.tsx index 48355514bc..93b073c91b 100644 --- a/libs/tangle-shared-ui/src/context/RestakeContext.tsx +++ b/libs/tangle-shared-ui/src/context/RestakeContext.tsx @@ -20,6 +20,7 @@ import { } from 'react'; import { useAccount, useChainId } from 'wagmi'; import { Address } from 'viem'; +import EVMChainId from '@tangle-network/dapp-types/EVMChainId'; import { useEnvioHealthCheckByChainId } from '../utils/checkEnvioHealth'; import { useRestakingAssets, @@ -31,6 +32,7 @@ import { type RestakeAssetMap, } from '../data/graphql/useRestakeAssets'; import { useDelegator, type Delegator } from '../data/graphql/useDelegator'; +import { useOnChainDelegator } from '../data/restake/useOnChainDelegator'; import { useOperatorMap, type Operator } from '../data/graphql/useOperators'; import { useProtocolConfig, @@ -100,7 +102,8 @@ interface RestakeProviderProps { export const RestakeProvider: FC = ({ children }) => { const chainId = useChainId(); const { address: userAddress } = useAccount(); - const isLocalDev = chainId === 84532 || chainId === 31337; + const isLocalDev = + chainId === EVMChainId.BaseSepolia || chainId === EVMChainId.AnvilLocal; // Check indexer health const { data: isHealthy, isLoading: isCheckingHealth } = @@ -146,13 +149,57 @@ export const RestakeProvider: FC = ({ children }) => { enabled: !isCheckingHealth, }); - // Fetch delegator info (user's positions and pending requests) + // Get token addresses from assets for on-chain delegator query + const tokenAddresses = useMemo(() => { + return assetList.map((asset) => asset.id); + }, [assetList]); + + // Fetch delegator info from GraphQL (for rich data like delegations, requests) const { - data: delegator, - isLoading: isLoadingDelegator, - refetch: refetchDelegator, + data: graphqlDelegator, + isLoading: isLoadingGraphqlDelegator, + refetch: refetchGraphqlDelegator, } = useDelegator(userAddress); + // ALWAYS fetch delegator deposit data from on-chain for accurate amounts + // The indexer may have stale delegatedAmount values + const { + data: onChainDelegator, + isLoading: isLoadingOnChainDelegator, + refetch: refetchOnChainDelegator, + } = useOnChainDelegator(userAddress, { + tokenAddresses, + enabled: !!userAddress && tokenAddresses.length > 0, + }); + + // Merge data: use on-chain for accurate deposit/delegation amounts, + // GraphQL for rich data (delegations, requests, etc.) + const delegator = useMemo(() => { + // If we have on-chain data, use it for asset positions (source of truth) + if (onChainDelegator) { + // If we also have GraphQL data, merge them + if (graphqlDelegator) { + return { + ...graphqlDelegator, + // Override with on-chain data for accurate amounts + assetPositions: onChainDelegator.assetPositions, + totalDeposited: onChainDelegator.totalDeposited, + totalDelegated: onChainDelegator.totalDelegated, + }; + } + // Only on-chain data available + return onChainDelegator; + } + return graphqlDelegator ?? null; + }, [onChainDelegator, graphqlDelegator]); + + const isLoadingDelegator = + isLoadingOnChainDelegator || isLoadingGraphqlDelegator; + + const refetchDelegator = useCallback(async () => { + await Promise.all([refetchOnChainDelegator(), refetchGraphqlDelegator()]); + }, [refetchOnChainDelegator, refetchGraphqlDelegator]); + // Fetch operators const { data: operatorMap, diff --git a/libs/tangle-shared-ui/src/data/graphql/index.ts b/libs/tangle-shared-ui/src/data/graphql/index.ts index a2ba2a3453..2af2a1f8e6 100644 --- a/libs/tangle-shared-ui/src/data/graphql/index.ts +++ b/libs/tangle-shared-ui/src/data/graphql/index.ts @@ -18,13 +18,13 @@ export { useDelegatorDeposits, useDelegatorDelegations, useDelegatorWithdrawRequests, - useDelegatorUnstakeRequests, + useDelegatorUndelegateRequests, useDelegatorCount, type Delegator, type DelegatorAssetPosition, type DelegationPosition, type WithdrawRequest, - type UnstakeRequest, + type UndelegateRequest, type RequestStatus, type LockDuration, type BlueprintSelectionMode, @@ -65,7 +65,7 @@ export { export { useProtocolConfig, useWithdrawalDelay, - useUnstakeDelay, + useUndelegateDelay, type ProtocolConfig, } from './useProtocolConfig'; diff --git a/libs/tangle-shared-ui/src/data/graphql/useDelegator.ts b/libs/tangle-shared-ui/src/data/graphql/useDelegator.ts index bd9026f383..418171c59e 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useDelegator.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useDelegator.ts @@ -15,6 +15,7 @@ import { } from '../../utils/executeEnvioGraphQL'; import { useEnvioHealthCheckByChainId } from '../../utils/checkEnvioHealth'; import useNetworkStore from '../../context/useNetworkStore'; +import { parseAddressLowercase } from '../../utils/safeParseAddress'; // Request status enum export type RequestStatus = 'PENDING' | 'READY' | 'EXECUTED' | 'CANCELLED'; @@ -65,8 +66,8 @@ export interface WithdrawRequest { executedAt: bigint | null; } -// Unstake request -export interface UnstakeRequest { +// Undelegate request (mapped from GraphQL unstakeRequests) +export interface UndelegateRequest { id: string; operatorId: Address; token: Address; @@ -92,7 +93,7 @@ export interface Delegator { assetPositions: DelegatorAssetPosition[]; delegations: DelegationPosition[]; withdrawRequests: WithdrawRequest[]; - unstakeRequests: UnstakeRequest[]; + unstakeRequests: UndelegateRequest[]; } // GraphQL query for delegator (Hasura uses _by_pk for single row queries) @@ -212,12 +213,27 @@ interface DelegatorQueryResult { } | null; } +/** + * Parses a string to Address, using lowercase normalization. + * Indexer data may have inconsistent casing, so we normalize to lowercase. + * Throws if the address is invalid to fail fast on bad data. + */ +const parseAddress = (value: string): Address => { + const parsed = parseAddressLowercase(value); + + if (!parsed) { + throw new Error(`Invalid address from indexer: ${value}`); + } + + return parsed; +}; + // Parse delegator from GraphQL response const parseDelegator = ( raw: NonNullable, ): Delegator => ({ id: raw.id, - address: raw.address as Address, + address: parseAddress(raw.address), totalDeposited: BigInt(raw.totalDeposited), totalDelegated: BigInt(raw.totalDelegated), createdAt: BigInt(raw.createdAt), @@ -226,7 +242,7 @@ const parseDelegator = ( unstakeNonce: BigInt(raw.unstakeNonce), assetPositions: raw.assetPositions.map((pos) => ({ id: pos.id, - token: pos.token as Address, + token: parseAddress(pos.token), totalDeposited: BigInt(pos.totalDeposited), delegatedAmount: BigInt(pos.delegatedAmount), lockedAmount: BigInt(pos.lockedAmount), @@ -234,8 +250,8 @@ const parseDelegator = ( })), delegations: raw.delegations.map((del) => ({ id: del.id, - operatorId: del.operator.id as Address, - token: del.token as Address, + operatorId: parseAddress(del.operator.id), + token: parseAddress(del.token), shares: BigInt(del.shares), lastKnownAmount: BigInt(del.lastKnownAmount), blueprintSelection: del.blueprintSelection, @@ -247,7 +263,7 @@ const parseDelegator = ( })), withdrawRequests: raw.withdrawRequests.map((req) => ({ id: req.id, - token: req.token as Address, + token: parseAddress(req.token), nonce: BigInt(req.nonce), amount: BigInt(req.amount), requestedRound: BigInt(req.requestedRound), @@ -257,8 +273,8 @@ const parseDelegator = ( })), unstakeRequests: raw.unstakeRequests.map((req) => ({ id: req.id, - operatorId: req.operator.id as Address, - token: req.token as Address, + operatorId: parseAddress(req.operator.id), + token: parseAddress(req.token), nonce: BigInt(req.nonce), shares: BigInt(req.shares), estimatedAmount: BigInt(req.estimatedAmount), @@ -383,8 +399,8 @@ export const useDelegatorWithdrawRequests = ( }; }; -// Hook to get delegator's pending unstake requests -export const useDelegatorUnstakeRequests = ( +// Hook to get delegator's pending undelegate requests +export const useDelegatorUndelegateRequests = ( address: Address | undefined, options?: { network?: EnvioNetwork; diff --git a/libs/tangle-shared-ui/src/data/graphql/useProtocolConfig.ts b/libs/tangle-shared-ui/src/data/graphql/useProtocolConfig.ts index 46918b68c9..dc2ab5205d 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useProtocolConfig.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useProtocolConfig.ts @@ -244,9 +244,9 @@ export const useWithdrawalDelay = () => { }; /** - * Hook to get unstake delay in human-readable format. + * Hook to get undelegate delay in human-readable format. */ -export const useUnstakeDelay = () => { +export const useUndelegateDelay = () => { const { data: config, isLoading } = useProtocolConfig(); if (isLoading || !config) { diff --git a/libs/tangle-shared-ui/src/data/graphql/useRestakingAssets.ts b/libs/tangle-shared-ui/src/data/graphql/useRestakingAssets.ts index 85c389ec87..a794c4943e 100644 --- a/libs/tangle-shared-ui/src/data/graphql/useRestakingAssets.ts +++ b/libs/tangle-shared-ui/src/data/graphql/useRestakingAssets.ts @@ -5,13 +5,14 @@ import { useQuery } from '@tanstack/react-query'; import { Address } from 'viem'; -import { useChainId } from 'wagmi'; +import { useAccount, useChainId } from 'wagmi'; import { executeEnvioGraphQL, gql, EnvioNetwork, getEnvioNetworkFromChainId, } from '../../utils/executeEnvioGraphQL'; +import useNetworkStore from '../../context/useNetworkStore'; import type { RestakingAsset } from '../restake/types'; // Re-export for backwards compatibility @@ -90,7 +91,10 @@ export const useRestakingAssets = (options?: { }) => { const { network, enabledOnly = true, enabled = true } = options ?? {}; const chainId = useChainId(); - const resolvedNetwork = network ?? getEnvioNetworkFromChainId(chainId); + const { isConnected } = useAccount(); + const networkChainId = useNetworkStore((store) => store.network2?.evmChainId); + const activeChainId = isConnected ? chainId : (networkChainId ?? chainId); + const resolvedNetwork = network ?? getEnvioNetworkFromChainId(activeChainId); return useQuery({ queryKey: ['envio', 'restakingAssets', resolvedNetwork, enabledOnly], @@ -110,7 +114,10 @@ export const useRestakingAssetMap = (options?: { }) => { const { network, enabledOnly = true, enabled = true } = options ?? {}; const chainId = useChainId(); - const resolvedNetwork = network ?? getEnvioNetworkFromChainId(chainId); + const { isConnected } = useAccount(); + const networkChainId = useNetworkStore((store) => store.network2?.evmChainId); + const activeChainId = isConnected ? chainId : (networkChainId ?? chainId); + const resolvedNetwork = network ?? getEnvioNetworkFromChainId(activeChainId); return useQuery({ queryKey: ['envio', 'restakingAssetMap', resolvedNetwork, enabledOnly], diff --git a/libs/tangle-shared-ui/src/data/tx/index.ts b/libs/tangle-shared-ui/src/data/tx/index.ts index 175ce7e1c2..ad075ae58f 100644 --- a/libs/tangle-shared-ui/src/data/tx/index.ts +++ b/libs/tangle-shared-ui/src/data/tx/index.ts @@ -9,11 +9,11 @@ export { useDepositTx, type DepositParams } from './useDepositTx'; // Delegate export { useDelegateTx, type DelegateParams } from './useDelegateTx'; -// Undelegate (unstake) +// Undelegate export { - useScheduleUnstakeTx, - useExecuteUnstakeTx, - type ScheduleUnstakeParams, + useScheduleUndelegateTx, + useExecuteUndelegateTx, + type ScheduleUndelegateParams, } from './useUndelegateTx'; // Withdraw diff --git a/libs/tangle-shared-ui/src/data/tx/useUndelegateTx.ts b/libs/tangle-shared-ui/src/data/tx/useUndelegateTx.ts index 4af904e597..de8d798b00 100644 --- a/libs/tangle-shared-ui/src/data/tx/useUndelegateTx.ts +++ b/libs/tangle-shared-ui/src/data/tx/useUndelegateTx.ts @@ -1,5 +1,5 @@ /** - * Hooks for undelegating (scheduling unstake) from an operator. + * Hooks for undelegating from an operator. * Replaces the Substrate-based useRestakeUndelegateTx hook. */ @@ -9,20 +9,20 @@ import MULTI_ASSET_DELEGATION_ABI from '../../abi/multiAssetDelegation'; import { getContractsByChainId } from '@tangle-network/dapp-config/contracts'; import { useChainId } from 'wagmi'; -export interface ScheduleUnstakeParams { +export interface ScheduleUndelegateParams { operator: Address; token: Address; amount: bigint; } /** - * Hook to schedule unstaking shares from an operator. - * This initiates the unstaking process - shares will be available - * for withdrawal after the unstaking delay (in rounds). + * Hook to schedule undelegation from an operator. + * This initiates the undelegation process - shares will be available + * for withdrawal after the undelegation delay (in rounds). * * @example * ```tsx - * const { execute, status, error } = useScheduleUnstakeTx(); + * const { execute, status, error } = useScheduleUndelegateTx(); * * await execute({ * operator: '0x...', @@ -31,13 +31,13 @@ export interface ScheduleUnstakeParams { * }); * ``` */ -export const useScheduleUnstakeTx = () => { +export const useScheduleUndelegateTx = () => { const chainId = useChainId(); const contracts = getContractsByChainId(chainId); return useContractWrite( MULTI_ASSET_DELEGATION_ABI, - (params: ScheduleUnstakeParams, _activeAddress) => ({ + (params: ScheduleUndelegateParams, _activeAddress) => ({ address: contracts.multiAssetDelegation, abi: MULTI_ASSET_DELEGATION_ABI, functionName: 'scheduleDelegatorUnstake' as const, @@ -51,16 +51,16 @@ export const useScheduleUnstakeTx = () => { ['Token', params.token], ['Amount', params.amount.toString()], ]), - getSuccessMessage: () => 'Successfully scheduled unstake', + getSuccessMessage: () => 'Successfully scheduled undelegate', }, ); }; /** - * Hook to execute a scheduled unstake after the delay period. + * Hook to execute a scheduled undelegate after the delay period. * Call this after the readyAtRound has been reached. */ -export const useExecuteUnstakeTx = () => { +export const useExecuteUndelegateTx = () => { const chainId = useChainId(); const contracts = getContractsByChainId(chainId); @@ -74,9 +74,9 @@ export const useExecuteUnstakeTx = () => { }), { txName: 'restake execute undelegate', - getSuccessMessage: () => 'Successfully executed unstake', + getSuccessMessage: () => 'Successfully executed undelegate', }, ); }; -export default useScheduleUnstakeTx; +export default useScheduleUndelegateTx; diff --git a/libs/tangle-shared-ui/src/utils/fetchErc20TokenMetadata.ts b/libs/tangle-shared-ui/src/utils/fetchErc20TokenMetadata.ts index abb1084ea2..671e1e7446 100644 --- a/libs/tangle-shared-ui/src/utils/fetchErc20TokenMetadata.ts +++ b/libs/tangle-shared-ui/src/utils/fetchErc20TokenMetadata.ts @@ -3,8 +3,10 @@ import { EvmAddress } from '@tangle-network/ui-components/types/address'; import { ContractFunctionName, erc20Abi, PublicClient } from 'viem'; import { z } from 'zod'; import { cacheTokenMetadata } from '@tangle-network/dapp-config/tokenMetadata'; +import EVMChainId from '@tangle-network/dapp-types/EVMChainId'; -const DEFAULT_CHAIN_ID = 1; // Fallback to Ethereum mainnet if chain ID unavailable +/** Fallback to Ethereum mainnet if chain ID unavailable */ +const DEFAULT_CHAIN_ID = EVMChainId.EthereumMainNet; type TokenMetadata = { id: EvmAddress; diff --git a/libs/tangle-shared-ui/src/utils/parseTransactionError.ts b/libs/tangle-shared-ui/src/utils/parseTransactionError.ts new file mode 100644 index 0000000000..9e93d7e39d --- /dev/null +++ b/libs/tangle-shared-ui/src/utils/parseTransactionError.ts @@ -0,0 +1,124 @@ +/** + * Parses transaction errors into user-friendly messages. + * + * This utility handles common blockchain transaction errors and provides + * clear, actionable messages for users. + */ + +/** Common transaction error patterns and their user-friendly messages */ +const ERROR_PATTERNS: Array<{ pattern: string; message: string }> = [ + { + pattern: 'intrinsic gas too low', + message: 'Transaction failed: Gas limit too low. Please try again.', + }, + { + pattern: 'insufficient funds', + message: 'Insufficient funds to cover gas fees.', + }, + { + pattern: 'user rejected', + message: 'Transaction was rejected by user.', + }, + { + pattern: 'User rejected', + message: 'Transaction was rejected by user.', + }, + { + pattern: 'Failed to fetch', + message: + 'Network request failed. Please check your connection and try again.', + }, + { + pattern: 'nonce too low', + message: 'Transaction nonce conflict. Please try again.', + }, + { + pattern: 'already claimed', + message: 'This address has already claimed its allocation.', + }, + { + pattern: 'InvalidMerkleProof', + message: + 'Invalid Merkle proof for this account and amount. Double-check you selected the correct account.', + }, + { + pattern: 'invalid proof', + message: 'Invalid proof. Please regenerate and try again.', + }, + { + pattern: 'not in merkle tree', + message: 'Address not found in the merkle tree.', + }, + { + pattern: 'claim period ended', + message: 'The claim period has ended.', + }, + { + pattern: 'paused', + message: 'This operation is currently paused.', + }, + { + pattern: 'execution reverted', + message: 'Transaction execution reverted. Please try again.', + }, + { + pattern: 'timeout', + message: 'Transaction timed out. Please try again.', + }, + { + pattern: 'replacement fee too low', + message: 'Gas price too low to replace pending transaction.', + }, + { + pattern: 'already known', + message: 'Transaction already submitted. Please wait for confirmation.', + }, +]; + +/** Maximum length for error messages before truncation */ +const MAX_ERROR_LENGTH = 100; + +/** + * Parses a transaction error into a user-friendly message. + * + * @param error - The error object or string to parse + * @returns A user-friendly error message + * + * @example + * ```ts + * try { + * await submitTransaction(); + * } catch (err) { + * const message = parseTransactionError(err as Error); + * toast.error(message); + * } + * ``` + */ +const parseTransactionError = (error: Error | string): string => { + const message = typeof error === 'string' ? error : error.message || ''; + const lowerMessage = message.toLowerCase(); + + // Check for known error patterns + for (const { pattern, message: errorMessage } of ERROR_PATTERNS) { + if (lowerMessage.includes(pattern.toLowerCase())) { + return errorMessage; + } + } + + // Extract "Details:" section if present (common in some RPC errors) + const detailsMatch = message.match(/Details:\s*([^V]+?)(?:Version:|$)/); + + if (detailsMatch) { + return detailsMatch[1].trim(); + } + + // Fallback: truncate long messages + if (message.length > MAX_ERROR_LENGTH) { + return 'Transaction failed. Please try again.'; + } + + // Return the original message if it's reasonably short + return message || 'An unknown error occurred.'; +}; + +export default parseTransactionError; diff --git a/libs/tangle-shared-ui/src/utils/safeParseAddress.ts b/libs/tangle-shared-ui/src/utils/safeParseAddress.ts new file mode 100644 index 0000000000..cb6157b5ca --- /dev/null +++ b/libs/tangle-shared-ui/src/utils/safeParseAddress.ts @@ -0,0 +1,133 @@ +/** + * Safe address parsing utilities for converting strings to typed Address values. + * + * These utilities help reduce unsafe `as Address` type assertions by providing + * proper validation before type conversion. + */ + +import { Address, getAddress, isAddress } from 'viem'; + +/** + * Safely parses a string to a viem Address type. + * Returns the checksummed address if valid, or null if invalid. + * + * @param value - The string value to parse as an address + * @returns The checksummed Address or null if invalid + * + * @example + * ```ts + * const address = safeParseAddress(rawData.address); + * if (address) { + * // address is typed as Address + * console.log('Valid address:', address); + * } + * ``` + */ +export const safeParseAddress = (value: string | undefined): Address | null => { + if (!value || !isAddress(value)) { + return null; + } + + try { + return getAddress(value); + } catch { + return null; + } +}; + +/** + * Parses a string to an Address type, throwing an error if invalid. + * Use this when you expect the value to always be a valid address. + * + * @param value - The string value to parse as an address + * @param fieldName - Optional field name for better error messages + * @returns The checksummed Address + * @throws Error if the value is not a valid address + * + * @example + * ```ts + * // In a parser where address is required + * const address = parseAddressOrThrow(rawData.address, 'delegator address'); + * ``` + */ +export const parseAddressOrThrow = ( + value: string | undefined, + fieldName = 'address', +): Address => { + if (!value) { + throw new Error(`${fieldName} is required but was undefined`); + } + + if (!isAddress(value)) { + throw new Error(`${fieldName} is not a valid EVM address: ${value}`); + } + + try { + return getAddress(value); + } catch (error) { + throw new Error( + `Failed to parse ${fieldName}: ${error instanceof Error ? error.message : 'unknown error'}`, + ); + } +}; + +/** + * Parses a string to a lowercase Address type. + * Useful for matching addresses that may have inconsistent casing (e.g., from indexers). + * + * @param value - The string value to parse as an address + * @returns The lowercase Address or null if invalid + * + * @example + * ```ts + * // For indexer data that may have inconsistent casing + * const address = parseAddressLowercase(indexerData.token); + * ``` + */ +export const parseAddressLowercase = ( + value: string | undefined, +): Address | null => { + if (!value || !isAddress(value)) { + return null; + } + + return value.toLowerCase() as Address; +}; + +/** + * Validates and normalizes an address, returning a consistent format. + * Useful for comparing addresses from different sources. + * + * @param value - The string value to normalize + * @returns The checksummed Address or null if invalid + */ +export const normalizeAddress = (value: string | undefined): Address | null => { + return safeParseAddress(value); +}; + +/** + * Checks if two addresses are equal (case-insensitive comparison). + * + * @param a - First address to compare + * @param b - Second address to compare + * @returns true if addresses are equal, false otherwise + * + * @example + * ```ts + * if (addressesEqual(userAddress, tokenAddress)) { + * // addresses match + * } + * ``` + */ +export const addressesEqual = ( + a: string | undefined, + b: string | undefined, +): boolean => { + if (!a || !b) { + return false; + } + + return a.toLowerCase() === b.toLowerCase(); +}; + +export default safeParseAddress;