diff --git a/apps/tangle-dapp/public/data/credits-tree.json b/apps/tangle-dapp/public/data/credits-tree.json new file mode 100644 index 0000000000..1e32288c15 --- /dev/null +++ b/apps/tangle-dapp/public/data/credits-tree.json @@ -0,0 +1,25 @@ +{ + "epochId": "1", + "root": "0x573194fb391684e4863d727812079b5df367353a88268e8f3473ec0faecf678e", + "totalValue": "3000", + "entryCount": 2, + "startTs": "1767084038", + "endTs": "1767688838", + "epochSeconds": "604800", + "tntToken": "0x5eb3bc0a489c5a8288765d2336659ebca68fcd00", + "creditsPerTnt": "1", + "entries": { + "0x15d34aaf54267db7d7c367839aaf71a00a2c6a65": { + "amount": "1000", + "proof": [ + "0x77cae3945c7ee93ef32d231aa007bf98eecc25ed440cf40263f8b987156321e4" + ] + }, + "0x90f79bf6eb2c4f870365e785982e1f101e93b906": { + "amount": "2000", + "proof": [ + "0xb1566ed9df667651f12a257e29b09690e01dc5f2488dd5c2fe8b16a95b7955f7" + ] + } + } +} \ No newline at end of file diff --git a/apps/tangle-dapp/src/data/credits/useCredits.ts b/apps/tangle-dapp/src/data/credits/useCredits.ts index a2a4071b17..f514ce5f91 100644 --- a/apps/tangle-dapp/src/data/credits/useCredits.ts +++ b/apps/tangle-dapp/src/data/credits/useCredits.ts @@ -1,7 +1,7 @@ import { useMemo } from 'react'; -import { useReadContract, useChainId } from 'wagmi'; +import { useChainId, useReadContract } from 'wagmi'; import { useQuery } from '@tanstack/react-query'; -import { type Hex, zeroAddress } from 'viem'; +import { type Hex, type Address } from 'viem'; import useEvmAddress from '@tangle-network/tangle-shared-ui/hooks/useEvmAddress'; import CREDITS_MERKLE_ABI from '@tangle-network/tangle-shared-ui/abi/creditsMerkle'; import { @@ -84,7 +84,7 @@ export default function useCredits() { error: rootError, refetch: refetchRoot, } = useReadContract({ - address: creditsAddress ?? zeroAddress, + address: creditsAddress as Address, abi: CREDITS_MERKLE_ABI, functionName: 'merkleRoots', args: claimData ? [claimData.epochId] : undefined, @@ -100,16 +100,17 @@ export default function useCredits() { error: claimedError, refetch: refetchClaimed, } = useReadContract({ - address: creditsAddress ?? zeroAddress, + address: creditsAddress as Address, abi: CREDITS_MERKLE_ABI, functionName: 'claimed', args: claimData && activeEvmAddress - ? [claimData.epochId, activeEvmAddress as `0x${string}`] + ? [claimData.epochId, activeEvmAddress as Address] : undefined, query: { enabled: isSupportedNetwork && claimData !== null && activeEvmAddress !== null, + staleTime: 10000, refetchInterval: 10000, }, }); @@ -118,7 +119,7 @@ export default function useCredits() { if (!claimData || !onchainRoot) { return false; } - return claimData.root.toLowerCase() === (onchainRoot as Hex).toLowerCase(); + return claimData.root.toLowerCase() === onchainRoot.toLowerCase(); }, [claimData, onchainRoot]); const proofValid = useMemo(() => { diff --git a/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsButton.tsx b/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsButton.tsx index e7495e88ef..75a4169b58 100644 --- a/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsButton.tsx +++ b/apps/tangle-dapp/src/features/claimCredits/components/ClaimCreditsButton.tsx @@ -1,15 +1,14 @@ import { CrossCircledIcon } from '@radix-ui/react-icons'; -import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config'; import { Spinner } from '@tangle-network/icons'; import { SparklingIcon } from '@tangle-network/icons'; import { TxStatus } from '@tangle-network/tangle-shared-ui/hooks/useSubstrateTx'; -import { BN } from '@polkadot/util'; import { - AmountFormatStyle, Button, - formatDisplayAmount, Typography, TextField, + Tooltip, + TooltipBody, + TooltipTrigger, } from '@tangle-network/ui-components'; import { Dropdown, @@ -22,6 +21,10 @@ import useClaimCreditsTx from '../../../data/credits/useClaimCreditsTx'; import { meetsMinimumClaimThreshold } from '../../../utils/creditConstraints'; import CreditVelocityTooltip from './CreditVelocityTooltip'; +const SECONDS_PER_DAY = 86400; +const SECONDS_PER_HOUR = 3600; +const SECONDS_PER_MINUTE = 60; + const ClaimCreditsButton = () => { const { data, error, refetch, isPending, isSupportedNetwork } = useCredits(); const [offchainAccountId, setOffchainAccountId] = useState(''); @@ -32,11 +35,7 @@ const ClaimCreditsButton = () => { return '0'; } - return formatDisplayAmount( - new BN(data.amount.toString()), - TANGLE_TOKEN_DECIMALS, - AmountFormatStyle.SHORT, - ); + return data.amount.toString(); }, [data]); const meetsMinimumThreshold = useMemo(() => { @@ -49,9 +48,9 @@ const ClaimCreditsButton = () => { } const total = Number(data.timeRemaining); - const days = Math.floor(total / 86400); - const hours = Math.floor((total % 86400) / 3600); - const minutes = Math.floor((total % 3600) / 60); + const days = Math.floor(total / SECONDS_PER_DAY); + const hours = Math.floor((total % SECONDS_PER_DAY) / SECONDS_PER_HOUR); + const minutes = Math.floor((total % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE); if (days > 0) return `${days}d ${hours}h`; if (hours > 0) return `${hours}h ${minutes}m`; return `${minutes}m`; @@ -71,31 +70,44 @@ const ClaimCreditsButton = () => { return error.message; }, [error]); + const buttonContent = ( + + ) : error || isUnavailable ? ( + + ) : ( + + ) + } + > + + {isPending + ? 'Fetching credits...' + : isUnavailable + ? 'Credits unavailable' + : error + ? 'Error' + : formattedCredits} + + + ); + return ( - - ) : error || isUnavailable ? ( - - ) : ( - - ) - } - > - - {isPending - ? 'Fetching credits...' - : isUnavailable - ? 'Credits unavailable' - : error - ? errorLabel - : formattedCredits} - - + {error ? ( + + {buttonContent} + + {errorLabel} + + + ) : ( + buttonContent + )} {isUnavailable ? ( @@ -227,7 +239,15 @@ const CreditsButton = ({ } if (execute === null || !credits || !epochId || !merkleProof) { - return null; + if (process.env.NODE_ENV === 'development') { + console.warn('Claim credits: Missing required data', { + hasExecute: execute !== null, + hasCredits: !!credits, + hasEpochId: !!epochId, + hasMerkleProof: !!merkleProof, + }); + } + return; } try { @@ -241,7 +261,9 @@ const CreditsButton = ({ await refetchCredits(); setOffchainAccountId(''); } catch (error) { - console.error('Failed to claim credits:', error); + if (process.env.NODE_ENV === 'development') { + console.error('Failed to claim credits:', error); + } } }, [ credits, diff --git a/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx b/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx index 5b0d0785f9..c9873c2357 100644 --- a/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx +++ b/apps/tangle-dapp/src/features/claimCredits/components/CreditVelocityTooltip.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useMemo } from 'react'; import { InfoCircledIcon } from '@radix-ui/react-icons'; import { Typography } from '@tangle-network/ui-components/typography/Typography'; import { @@ -26,19 +26,30 @@ const CreditVelocityTooltip: FC = ({ currentAmount, tokenSymbol = 'TNT', }) => { - const creditsNeeded = getCreditsNeededForMinimum(currentAmount); + const creditsNeeded = useMemo( + () => getCreditsNeededForMinimum(currentAmount), + [currentAmount], + ); + // Convert decimal values to token units (multiply by 10^decimals) for BN const formattedMinimum = formatDisplayAmount( - new BN(MINIMUM_CLAIMABLE_CREDITS.toString()), + new BN( + BigInt( + Math.round(MINIMUM_CLAIMABLE_CREDITS * 10 ** TANGLE_TOKEN_DECIMALS), + ).toString(), + ), TANGLE_TOKEN_DECIMALS, AmountFormatStyle.SHORT, ); - const formattedCreditsNeeded = formatDisplayAmount( - new BN(creditsNeeded.toString()), - TANGLE_TOKEN_DECIMALS, - AmountFormatStyle.SHORT, - ); + // Format creditsNeeded to avoid floating-point display artifacts + const formattedCreditsNeeded = useMemo(() => { + if (creditsNeeded === 0) { + return '0'; + } + // Format to max 4 decimal places, removing trailing zeros + return creditsNeeded.toFixed(4).replace(/\.?0+$/, ''); + }, [creditsNeeded]); return ( @@ -55,7 +66,7 @@ const CreditVelocityTooltip: FC = ({ You need at least {formattedMinimum} {tokenSymbol} to claim credits. - {creditsNeeded !== BigInt(0) && ( + {creditsNeeded !== 0 && ( = MINIMUM_CLAIMABLE_CREDITS; + // Scale the amount for comparison to avoid floating-point precision issues + const scaledAmount = amount * CREDIT_SCALE; + return scaledAmount >= MINIMUM_CLAIMABLE_CREDITS_SCALED; }; /** * Calculates how much more credits are needed to reach the minimum threshold. + * Returns 0 if the minimum threshold is already met. */ export const getCreditsNeededForMinimum = ( amount: bigint | null | undefined, -): bigint => { +): number => { if (!amount) { return MINIMUM_CLAIMABLE_CREDITS; } - if (amount >= MINIMUM_CLAIMABLE_CREDITS) { - return BigInt(0); + // For display purposes, safe to convert to Number since credit amounts + // are raw amounts (not wei) and expected to be within safe integer range + const numAmount = Number(amount); + + if (numAmount >= MINIMUM_CLAIMABLE_CREDITS) { + return 0; } - return MINIMUM_CLAIMABLE_CREDITS - amount; + // Round to avoid floating-point precision artifacts + const scale = Number(CREDIT_SCALE); + return Math.round((MINIMUM_CLAIMABLE_CREDITS - numAmount) * scale) / scale; }; diff --git a/libs/dapp-config/src/contracts.ts b/libs/dapp-config/src/contracts.ts index 911c38f1bd..3a97ad149f 100644 --- a/libs/dapp-config/src/contracts.ts +++ b/libs/dapp-config/src/contracts.ts @@ -33,6 +33,9 @@ export const SP1_VERIFIER_GATEWAY = { }; // Local Anvil deployment addresses (from LocalTestnet.s.sol deployment) +// IMPORTANT: These are deterministic addresses based on deployer nonce when +// running LocalTestnet.s.sol on a fresh Anvil. If running multiple times or +// with a different deployer, addresses may differ. export const LOCAL_CONTRACTS: ContractAddresses = { tangle: '0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9', multiAssetDelegation: '0xe7f1725E7734CE288F8367e1Bb143E90bb3F0512', @@ -40,7 +43,7 @@ export const LOCAL_CONTRACTS: ContractAddresses = { operatorStatusRegistry: '0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf', rewardVaults: '0x21dF544947ba3E8b3c32561399E88B52Dc8b2823', inflationPool: '0xD8a5a9b31c3C0232E196d518E89Fd8bF83AcAd43', - credits: '0x1fA02b2d6A771842690194Cf62D91bdd92BfE28d', + credits: '0x5081a39b8A5f0E35a8D959395a630b68B74Dd30f', liquidDelegationFactory: '0x8F4ec854Dd12F1fe79500a1f53D0cbB30f9b6134', };