Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/tangle-dapp/public/data/credits-tree.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
}
}
13 changes: 7 additions & 6 deletions apps/tangle-dapp/src/data/credits/useCredits.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
},
});
Expand All @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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('');
Expand All @@ -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(() => {
Expand All @@ -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`;
Expand All @@ -71,31 +70,44 @@ const ClaimCreditsButton = () => {
return error.message;
}, [error]);

const buttonContent = (
<DropdownButton
disabled={isPending || error !== null || isUnavailable}
isHideArrowIcon={isPending || error !== null || isUnavailable}
icon={
isPending ? (
<Spinner size="lg" />
) : error || isUnavailable ? (
<CrossCircledIcon className="size-6" />
) : (
<SparklingIcon size="md" />
)
}
>
<span className="hidden sm:inline-block">
{isPending
? 'Fetching credits...'
: isUnavailable
? 'Credits unavailable'
: error
? 'Error'
: formattedCredits}
</span>
</DropdownButton>
);

return (
<Dropdown>
<DropdownButton
disabled={isPending || error !== null || isUnavailable}
isHideArrowIcon={isPending || error !== null || isUnavailable}
icon={
isPending ? (
<Spinner size="lg" />
) : error || isUnavailable ? (
<CrossCircledIcon className="size-6" />
) : (
<SparklingIcon size="md" />
)
}
>
<span className="hidden sm:inline-block">
{isPending
? 'Fetching credits...'
: isUnavailable
? 'Credits unavailable'
: error
? errorLabel
: formattedCredits}
</span>
</DropdownButton>
{error ? (
<Tooltip>
<TooltipTrigger asChild>{buttonContent}</TooltipTrigger>
<TooltipBody className="max-w-xs break-words">
{errorLabel}
</TooltipBody>
</Tooltip>
) : (
buttonContent
)}

<DropdownBody align="start" sideOffset={8} className="p-4 space-y-3">
{isUnavailable ? (
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -26,19 +26,30 @@ const CreditVelocityTooltip: FC<Props> = ({
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 (
<Tooltip>
Expand All @@ -55,7 +66,7 @@ const CreditVelocityTooltip: FC<Props> = ({
You need at least {formattedMinimum} {tokenSymbol} to claim credits.
</Typography>

{creditsNeeded !== BigInt(0) && (
{creditsNeeded !== 0 && (
<Typography
variant="body2"
className="text-mono-120 dark:text-mono-80"
Expand Down
38 changes: 29 additions & 9 deletions apps/tangle-dapp/src/utils/creditConstraints.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,25 @@
import { TANGLE_TOKEN_DECIMALS } from '@tangle-network/dapp-config';
/**
* Minimum claimable credit amount (in display units).
* Credits in the merkle tree are stored as raw amounts, not wei.
*/
export const MINIMUM_CLAIMABLE_CREDITS = 0.01;

/**
* Scale factor for credit precision (2 decimal places).
* Used to convert between display amounts and BigInt comparisons.
*/
const CREDIT_SCALE = BigInt(100);

/**
* Minimum claimable credit amount (0.01 tokens).
* Minimum claimable credits as BigInt (scaled by CREDIT_SCALE).
*/
export const MINIMUM_CLAIMABLE_CREDITS = BigInt(
Math.pow(10, TANGLE_TOKEN_DECIMALS - 2),
const MINIMUM_CLAIMABLE_CREDITS_SCALED = BigInt(
Math.round(MINIMUM_CLAIMABLE_CREDITS * Number(CREDIT_SCALE)),
);

/**
* Checks if the credit amount meets the minimum claimable threshold.
* Uses BigInt comparison to avoid precision loss with large values.
*/
export const meetsMinimumClaimThreshold = (
amount: bigint | null | undefined,
Expand All @@ -17,22 +28,31 @@ export const meetsMinimumClaimThreshold = (
return false;
}

return amount >= 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;
};
5 changes: 4 additions & 1 deletion libs/dapp-config/src/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,17 @@ 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',
masterBlueprintServiceManager: '0xa513E6E4b8f2a923D98304ec87F64353C4D5C853',
operatorStatusRegistry: '0x8f86403A4DE0BB5791fa46B8e795C547942fE4Cf',
rewardVaults: '0x21dF544947ba3E8b3c32561399E88B52Dc8b2823',
inflationPool: '0xD8a5a9b31c3C0232E196d518E89Fd8bF83AcAd43',
credits: '0x1fA02b2d6A771842690194Cf62D91bdd92BfE28d',
credits: '0x5081a39b8A5f0E35a8D959395a630b68B74Dd30f',
liquidDelegationFactory: '0x8F4ec854Dd12F1fe79500a1f53D0cbB30f9b6134',
};

Expand Down