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
85 changes: 85 additions & 0 deletions apps/tangle-cloud/src/pages/operators/manage/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,11 @@ import {
useSlashConfig,
useDisputeSlashTx,
useCancelSlashTx,
useClaimDisputeBondTx,
useProposeSlashTx,
useExecuteSlashTx,
useExecutableSlashes,
usePendingDisputeBondRefund,
useProposableServices,
getSlashDisputeEligibility,
getSlashExecutionEligibility,
Expand All @@ -50,6 +52,7 @@ import {
type SlashProposal,
} from '@tangle-network/tangle-shared-ui/data/graphql';
import { MembershipModel } from '@tangle-network/tangle-shared-ui/data/services';
import { formatUnits } from 'viem';
import {
createColumnHelper,
getCoreRowModel,
Expand Down Expand Up @@ -90,6 +93,12 @@ const shortenHex = (value: string, chars = 6) =>
? value
: `${value.slice(0, chars)}...${value.slice(-chars)}`;

const formatEthAmount = (wei: bigint): string => {
const formatted = formatUnits(wei, 18);
if (!formatted.includes('.')) return formatted;
return formatted.replace(/\.?0+$/, '');
};

type ButtonProps = Omit<
ComponentProps<typeof SandboxButton>,
'variant' | 'size'
Expand Down Expand Up @@ -350,6 +359,18 @@ const Page: FC = () => {
const { disputeSlash, status: disputeStatus } = useDisputeSlashTx();
const { cancelSlash, status: cancelStatus } = useCancelSlashTx();
const { executeSlash } = useExecuteSlashTx();
const {
claimDisputeBond,
status: claimDisputeBondStatus,
error: claimDisputeBondError,
reset: resetClaimDisputeBond,
} = useClaimDisputeBondTx();
const {
data: pendingDisputeBondRefund,
refetch: refetchPendingDisputeBondRefund,
} = usePendingDisputeBondRefund(address, {
enabled: isConnected && !!address,
});

// Registration modal state
const [selectedRegistration, setSelectedRegistration] =
Expand Down Expand Up @@ -765,6 +786,22 @@ const Page: FC = () => {
updatePreferences,
]);

const handleClaimDisputeBond = useCallback(async () => {
if (!address || (pendingDisputeBondRefund ?? BigInt(0)) <= BigInt(0)) {
return;
}

const result = await claimDisputeBond(address);
if (result) {
await refetchPendingDisputeBondRefund();
}
}, [
address,
claimDisputeBond,
pendingDisputeBondRefund,
refetchPendingDisputeBondRefund,
]);

// Registration columns
const registrationColumns = useMemo(
() => [
Expand Down Expand Up @@ -1238,6 +1275,54 @@ const Page: FC = () => {
isLoading={loadingSlashConfig}
/>

{(pendingDisputeBondRefund ?? BigInt(0)) > BigInt(0) ||
claimDisputeBondError ? (
<Card className="p-4 border border-emerald-500/20 bg-emerald-500/10">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<Text variant="body2" fw="bold">
Dispute bond refund available
</Text>
<Text variant="body3" className="text-muted-foreground mt-1">
{pendingDisputeBondRefund &&
pendingDisputeBondRefund > BigInt(0)
? `${formatEthAmount(pendingDisputeBondRefund)} ETH is ready to claim from cancelled disputes.`
: 'Refresh the refund balance after the previous claim attempt.'}
</Text>
{claimDisputeBondError ? (
<Text variant="body3" className="mt-2 !text-destructive">
{claimDisputeBondError.message ||
'Failed to claim dispute bond refund.'}
</Text>
) : null}
</div>
<div className="flex gap-2">
{claimDisputeBondError ? (
<Button
variant="secondary"
size="sm"
onClick={resetClaimDisputeBond}
>
Dismiss
</Button>
) : null}
<Button
variant="secondary"
size="sm"
isLoading={claimDisputeBondStatus === 'pending'}
isDisabled={
claimDisputeBondStatus === 'pending' ||
(pendingDisputeBondRefund ?? BigInt(0)) <= BigInt(0)
}
onClick={() => void handleClaimDisputeBond()}
>
Claim refund
</Button>
</div>
</div>
</Card>
) : null}

{clockError ? (
<Card className="p-4 border border-yellow-500/20 bg-yellow-500/10">
<Text variant="body2" className="text-muted-foreground">
Expand Down
14 changes: 12 additions & 2 deletions apps/tangle-cloud/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,23 @@ export default defineConfig({
if (id.includes('viem')) return 'viem';
if (id.includes('@tanstack')) return 'tanstack';
if (id.includes('react-router')) return 'router';
// Co-locate react/react-dom with @radix-ui. Splitting them lets
// Rollup hoist the CJS-interop helper (`_getDefaultExportFromCjs`)
// into whichever chunk evaluates first; on some content-hash
// layouts the helper landed in the radix chunk and the react
// chunk imported it back, forming a load-order cycle that
// crashed the app at module init with
// `Cannot read properties of undefined (reading 'forwardRef')`
// when radix's top-level `Qu.reduce(...)` ran before react's
// bindings had initialised. Co-resident avoids the cycle since
// radix depends on react and must evaluate after it anyway.
if (
id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/')
id.includes('node_modules/react-dom/') ||
id.includes('@radix-ui')
) {
return 'react';
}
if (id.includes('@radix-ui')) return 'radix';
return undefined;
},
},
Expand Down
11 changes: 9 additions & 2 deletions apps/tangle-dapp/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,13 +166,20 @@ export default defineConfig({
if (id.includes('node_modules/globalthis/')) return 'utils';
if (id.includes('@tanstack')) return 'tanstack';
if (id.includes('react-router')) return 'router';
// Co-locate react/react-dom with @radix-ui. See tangle-cloud's
// vite.config.ts for the full incident write-up — splitting
// react from radix lets Rollup hoist the CJS-interop helper
// into the radix chunk, forming a load-order cycle that crashes
// the app with `Cannot read properties of undefined (reading
// 'forwardRef')` when radix's top-level `Qu.reduce(...)` runs
// before react's bindings have initialised.
if (
id.includes('node_modules/react/') ||
id.includes('node_modules/react-dom/')
id.includes('node_modules/react-dom/') ||
id.includes('@radix-ui')
) {
return 'react';
}
if (id.includes('@radix-ui')) return 'radix';
return undefined;
},
},
Expand Down
2 changes: 2 additions & 0 deletions libs/tangle-shared-ui/src/data/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,13 @@ export {
useSlashConfig,
useDisputeSlashTx,
useCancelSlashTx,
useClaimDisputeBondTx,
useProposeSlashTx,
useExecuteSlashTx,
useExecuteSlashBatchTx,
useExecutableSlashes,
useSlashProposalDetails,
usePendingDisputeBondRefund,
formatSlashAmount,
formatSlashBps,
toSlashEvidenceBytes32,
Expand Down
105 changes: 102 additions & 3 deletions libs/tangle-shared-ui/src/data/graphql/useSlashing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* - Build UI guardrails and timeline states
*/

import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useAccount, useChainId, usePublicClient } from 'wagmi';
import { getContractsByChainId } from '@tangle-network/dapp-config/contracts';
import { Address, type Hash, isAddress, zeroAddress } from 'viem';
Expand Down Expand Up @@ -967,6 +967,47 @@ export const useSlashProposalDetails = (
});
};

/**
* Reads the pull-pattern dispute-bond refund balance for a disputer.
*/
export const usePendingDisputeBondRefund = (
account: Address | undefined,
options?: { enabled?: boolean },
) => {
const { enabled = true } = options ?? {};
const chainId = useChainId();
const publicClient = usePublicClient();

return useQuery({
queryKey: [
'slashing',
'pendingDisputeBondRefund',
chainId,
account?.toLowerCase() ?? null,
],
queryFn: async () => {
if (!account) {
return BigInt(0);
}
if (!publicClient) {
throw new Error('Public client not available');
}

const contracts = getContractsByChainId(chainId);
const refund = await publicClient.readContract({
address: contracts.tangle,
abi: TANGLE_ABI,
functionName: 'pendingDisputeBondRefund',
args: [account],
});

return BigInt(refund.toString());
},
enabled: enabled && !!account && !!publicClient,
staleTime: 15_000,
});
};

/**
* Reads executable slash IDs directly from the contract.
*/
Expand Down Expand Up @@ -1067,6 +1108,10 @@ interface ExecuteSlashBatchParams {
slashIds: bigint[];
}

interface ClaimDisputeBondParams {
account: Address;
}

const mapContractWriteStatus = (status: ContractTxStatus): TxStatus => {
switch (status) {
case ContractTxStatus.PROCESSING:
Expand Down Expand Up @@ -1342,8 +1387,6 @@ export const useExecuteSlashBatchTx = () => {
* NOTE (tnt-core v0.14.0): `cancelSlash` no longer auto-refunds the disputer's
* bond. After cancellation, the disputer must call `claimDisputeBond()` and
* may inspect their pending balance with `pendingDisputeBondRefund(address)`.
* TODO(v0.15.0): wire up a `useClaimDisputeBondTx` + dedicated UI affordance
* on the disputer-facing slash detail view (and surface the pending balance).
*/
export const useCancelSlashTx = () => {
const chainId = useChainId();
Expand Down Expand Up @@ -1388,6 +1431,62 @@ export const useCancelSlashTx = () => {
};
};

/**
* Claims the caller's accumulated dispute-bond refund after a disputed slash is
* cancelled. The contract accounts refunds by msg.sender, so no slash id is
* required.
*/
export const useClaimDisputeBondTx = () => {
const chainId = useChainId();
const queryClient = useQueryClient();

const hook = useContractWrite(
TANGLE_ABI,
(_params: ClaimDisputeBondParams) => {
let contracts: ReturnType<typeof getContractsByChainId>;
try {
contracts = getContractsByChainId(chainId);
} catch {
throw new Error('Tangle contract not available on this network');
}

return {
address: contracts.tangle,
abi: TANGLE_ABI,
functionName: 'claimDisputeBond' as const,
args: [] as const,
};
},
{
txName: 'claim dispute bond',
txDetails: (params) => new Map([['Account', params.account]]),
getSuccessMessage: () => 'Dispute bond refund claimed successfully.',
onSuccess: (_result, params) => {
queryClient.invalidateQueries({
queryKey: [
'slashing',
'pendingDisputeBondRefund',
chainId,
params.account.toLowerCase(),
],
});
},
},
);

const claimDisputeBond = async (account: Address): Promise<Hash | null> => {
const result = await hook.execute?.({ account });
return result?.hash ?? null;
};

return {
claimDisputeBond,
status: mapContractWriteStatus(hook.status),
error: hook.error,
reset: hook.reset,
};
};

/**
* Format slash amount for display.
*/
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@
"@fontsource/geist-mono": "^5.2.7",
"@fontsource/geist-sans": "^5.2.5",
"@hookform/resolvers": "^5.0.1",
"@mysten/dapp-kit": "^0.19.11",
"@mysten/dapp-kit": "^1.0.6",
"@nanostores/react": "^1.1.0",
"@ngneat/falso": "^7.3.0",
"@octokit/request": "^9.2.3",
Expand Down
Loading
Loading