From 8db4563a703fb85b3cb5a0775f7e1eca80b0089c Mon Sep 17 00:00:00 2001 From: 0age <37939117+0age@users.noreply.github.com> Date: Fri, 6 Dec 2024 11:22:56 -0800 Subject: [PATCH] feat(frontend): add transfer and withdrawal functionality - Add current balance display from indexer - Add transfer and withdrawal buttons - Implement forced withdrawal flow with timelock - Add Base and Base Sepolia chain support - Update contract ABI with withdrawal functions - Create dialog components for withdrawal actions - Update useCompact hook with withdrawal methods - Fix ESLint configuration --- frontend/eslint.config.js | 94 ++----- frontend/src/components/BalanceDisplay.tsx | 209 +++++++++----- .../src/components/ForcedWithdrawalDialog.tsx | 258 ++++++++++++++++++ .../InitiateForcedWithdrawalDialog.tsx | 107 ++++++++ frontend/src/components/Transfer.tsx | 124 +++++++++ frontend/src/config/wagmi.ts | 28 ++ frontend/src/constants/contracts.ts | 51 +++- frontend/src/hooks/useBalances.ts | 1 + frontend/src/hooks/useCompact.ts | 67 +++++ 9 files changed, 805 insertions(+), 134 deletions(-) create mode 100644 frontend/src/components/ForcedWithdrawalDialog.tsx create mode 100644 frontend/src/components/InitiateForcedWithdrawalDialog.tsx create mode 100644 frontend/src/components/Transfer.tsx create mode 100644 frontend/src/config/wagmi.ts diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index 4db0ecc..b7846b9 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -1,70 +1,30 @@ -import js from '@eslint/js' -import globals from 'globals' -import reactHooks from 'eslint-plugin-react-hooks' -import reactRefresh from 'eslint-plugin-react-refresh' -import tseslint from 'typescript-eslint' - -export default tseslint.config( - { - // Ignore node_modules and dist - ignores: ['node_modules/**/*', 'dist/**/*'] +module.exports = { + root: true, + env: { + browser: true, + es2020: true, }, - { - // Base config for all files - files: ['**/*.{js,jsx,ts,tsx}'], - extends: [js.configs.recommended], - languageOptions: { - ecmaVersion: 2020, - globals: { - ...globals.browser, - ...globals.es2020, - }, - parserOptions: { - ecmaFeatures: { - jsx: true - } - } - } + extends: [ + 'eslint:recommended', + 'plugin:@typescript-eslint/recommended', + 'plugin:react-hooks/recommended', + ], + ignorePatterns: ['dist', '.eslintrc.cjs'], + parser: '@typescript-eslint/parser', + plugins: ['react-refresh'], + rules: { + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + '@typescript-eslint/no-explicit-any': 'warn', }, - { - // TypeScript-specific config - files: ['**/*.{ts,tsx}'], - extends: [...tseslint.configs.recommended], - plugins: { - 'react-hooks': reactHooks, - 'react-refresh': reactRefresh, - }, - rules: { - ...reactHooks.configs.recommended.rules, - 'react-refresh/only-export-components': [ - 'warn', - { allowConstantExport: true }, - ], - '@typescript-eslint/no-explicit-any': 'warn', - '@typescript-eslint/no-unused-vars': ['error', { - argsIgnorePattern: '^_', - varsIgnorePattern: '^_' - }], - // Allow console.error for error logging - 'no-console': ['error', { allow: ['error'] }], - // Ensure return types are specified - '@typescript-eslint/explicit-function-return-type': ['error', { - allowExpressions: true, - allowTypedFunctionExpressions: true, - allowHigherOrderFunctions: true, - allowDirectConstAssertionInArrowFunctions: true, - allowConciseArrowFunctionExpressionsStartingWithVoid: true, - }], - }, - settings: { - react: { - version: 'detect' - } + overrides: [ + { + files: ['*.ts', '*.tsx'], + rules: { + '@typescript-eslint/explicit-function-return-type': 'off', + }, }, - languageOptions: { - parserOptions: { - project: ['./tsconfig.json'] - } - } - } -) + ], +} diff --git a/frontend/src/components/BalanceDisplay.tsx b/frontend/src/components/BalanceDisplay.tsx index 572e53f..cd0d438 100644 --- a/frontend/src/components/BalanceDisplay.tsx +++ b/frontend/src/components/BalanceDisplay.tsx @@ -1,5 +1,11 @@ -import { useAccount } from 'wagmi'; -import { useBalances } from '../hooks/useBalances'; +import { useState } from 'react' +import { useAccount } from 'wagmi' +import { useBalances } from '../hooks/useBalances' +import { useResourceLocks } from '../hooks/useResourceLocks' +import { formatUnits } from 'viem' +import { Transfer } from './Transfer' +import { InitiateForcedWithdrawalDialog } from './InitiateForcedWithdrawalDialog' +import { ForcedWithdrawalDialog } from './ForcedWithdrawalDialog' // Utility function to format reset period const formatResetPeriod = (seconds: number): string => { @@ -10,12 +16,17 @@ const formatResetPeriod = (seconds: number): string => { }; export function BalanceDisplay(): JSX.Element | null { - const { isConnected } = useAccount(); - const { balances, error, isLoading } = useBalances(); + const { isConnected } = useAccount() + const { balances, error, isLoading } = useBalances() + const { data: resourceLocksData, isLoading: resourceLocksLoading } = useResourceLocks() + const [isWithdrawalDialogOpen, setIsWithdrawalDialogOpen] = useState(false) + const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false) + const [selectedLockId, setSelectedLockId] = useState('') + const [selectedLock, setSelectedLock] = useState(null) if (!isConnected) return null; - if (isLoading) { + if (isLoading || resourceLocksLoading) { return (
@@ -61,84 +72,150 @@ export function BalanceDisplay(): JSX.Element | null {
- {balances.map((balance) => ( -
- {/* Header with Chain, Token Info, and Lock ID */} -
-
- Chain {balance.chainId} - {balance.token && ( - - {balance.token.name} ({balance.token.symbol}) + {balances.map((balance) => { + // Find matching resource lock from indexer data + const resourceLock = resourceLocksData?.resourceLocks.items.find( + (item) => item.resourceLock.lockId === balance.lockId + ) + + return ( +
+ {/* Header with Chain, Token Info, and Lock ID */} +
+
+ Chain {balance.chainId} + {balance.token && ( + + {balance.token.name} ({balance.token.symbol}) + + )} +
+
Lock ID: {balance.lockId}
+
+ + {/* Resource Lock Properties */} +
+ {balance.resourceLock?.resetPeriod && balance.resourceLock.resetPeriod > 0 && ( + + Reset Period: {formatResetPeriod(balance.resourceLock.resetPeriod)} + + )} + {balance.resourceLock?.isMultichain && ( + + Multichain )} + + {balance.withdrawalStatus === 0 ? 'Active' : 'Withdrawal Pending'} +
-
Lock ID: {balance.lockId}
-
- {/* Resource Lock Properties */} -
- {balance.resourceLock?.resetPeriod && balance.resourceLock.resetPeriod > 0 && ( - - Reset Period: {formatResetPeriod(balance.resourceLock.resetPeriod)} - - )} - {balance.resourceLock?.isMultichain && ( - - Multichain - - )} - - {balance.withdrawalStatus === 0 ? 'Active' : 'Withdrawal Pending'} - -
+ {/* Balances Grid */} +
+ {/* Left side - Current, Allocatable, and Allocated */} +
+
+
Current
+
+ {resourceLock && formatUnits(BigInt(resourceLock.balance), resourceLock.resourceLock.token.decimals)} + {balance.token?.symbol && ( + {balance.token.symbol} + )} +
+
- {/* Balances Grid */} -
- {/* Left side - Compact display of allocatable and allocated */} -
-
-
Allocatable
-
- {balance.formattedAllocatableBalance || balance.allocatableBalance} - {balance.token?.symbol && ( - {balance.token.symbol} - )} +
+
Allocatable
+
+ {balance.formattedAllocatableBalance || balance.allocatableBalance} + {balance.token?.symbol && ( + {balance.token.symbol} + )} +
+
+ +
+
Allocated
+
+ {balance.formattedAllocatedBalance || balance.allocatedBalance} + {balance.token?.symbol && ( + {balance.token.symbol} + )} +
-
-
Allocated
-
- {balance.formattedAllocatedBalance || balance.allocatedBalance} + {/* Right side - Emphasized available to allocate */} +
+
Available to Allocate
+
+ {balance.formattedAvailableBalance || balance.balanceAvailableToAllocate} {balance.token?.symbol && ( - {balance.token.symbol} + {balance.token.symbol} )}
- {/* Right side - Emphasized available to allocate */} -
-
Available to Allocate
-
- {balance.formattedAvailableBalance || balance.balanceAvailableToAllocate} - {balance.token?.symbol && ( - {balance.token.symbol} - )} + {/* Transfer and Withdrawal Actions */} + {resourceLock && ( +
+ { + setSelectedLockId(balance.lockId) + setIsWithdrawalDialogOpen(true) + }} + onDisableForceWithdraw={() => { + setSelectedLockId(balance.lockId) + setSelectedLock(null) + }} + />
-
+ )}
-
- ))} + ) + })}
+ + {/* Withdrawal Dialogs */} + setIsWithdrawalDialogOpen(false)} + lockId={selectedLockId} + resetPeriod={parseInt(balances.find(b => b.lockId === selectedLockId)?.resourceLock?.resetPeriod?.toString() || "0")} + /> + + setIsExecuteDialogOpen(false)} + lockId={selectedLockId} + maxAmount={selectedLock?.balance || '0'} + decimals={selectedLock?.decimals || 18} + symbol={selectedLock?.symbol || ''} + tokenName={selectedLock?.tokenName || ''} + chainId={parseInt(selectedLock?.chainId || '1')} + />
); } diff --git a/frontend/src/components/ForcedWithdrawalDialog.tsx b/frontend/src/components/ForcedWithdrawalDialog.tsx new file mode 100644 index 0000000..ac09e2c --- /dev/null +++ b/frontend/src/components/ForcedWithdrawalDialog.tsx @@ -0,0 +1,258 @@ +import { useState, useEffect } from 'react' +import { formatUnits, parseUnits, isAddress } from 'viem' +import { useAccount, useChainId } from 'wagmi' +import { switchNetwork } from '@wagmi/core' +import { useCompact } from '../hooks/useCompact' +import { useNotification } from '../context/NotificationContext' +import { config } from '../config/wagmi' + +interface ForcedWithdrawalDialogProps { + isOpen: boolean + onClose: () => void + lockId: string + maxAmount: string + decimals: number + symbol: string + tokenName: string + chainId: number +} + +export function ForcedWithdrawalDialog({ + isOpen, + onClose, + lockId, + maxAmount, + decimals, + symbol, + tokenName, + chainId: targetChainId +}: ForcedWithdrawalDialogProps) { + const { address } = useAccount() + const currentChainId = useChainId() + const [amountType, setAmountType] = useState<'max' | 'custom'>('max') + const [recipientType, setRecipientType] = useState<'self' | 'custom'>('self') + const [customAmount, setCustomAmount] = useState('') + const [customRecipient, setCustomRecipient] = useState('') + const [isLoading, setIsLoading] = useState(false) + const { forcedWithdrawal } = useCompact() + const { showNotification } = useNotification() + + // Switch network when dialog opens + useEffect(() => { + if (isOpen && targetChainId !== currentChainId) { + const switchToNetwork = async () => { + try { + showNotification({ + type: 'success', + title: 'Switching Network', + message: `Switching to chain ID ${targetChainId}...` + }) + + await switchNetwork(config, { chainId: targetChainId }) + // Wait a bit for the network switch to complete + await new Promise(resolve => setTimeout(resolve, 1000)) + } catch (error) { + console.error('Error switching network:', error) + showNotification({ + type: 'error', + title: 'Network Switch Failed', + message: error instanceof Error ? error.message : 'Failed to switch network' + }) + onClose() + } + } + + switchToNetwork() + } + }, [isOpen, targetChainId, currentChainId, showNotification, onClose]) + + const formattedMaxAmount = formatUnits(BigInt(maxAmount), decimals) + + const validateAmount = () => { + if (!customAmount) return null + + try { + // Check if amount is zero or negative + const numAmount = parseFloat(customAmount) + if (numAmount <= 0) { + return { type: 'error', message: 'Amount must be greater than zero' } + } + + // Check decimal places + const decimalParts = customAmount.split('.') + if (decimalParts.length > 1 && decimalParts[1].length > decimals) { + return { type: 'error', message: `Invalid amount (greater than ${decimals} decimals)` } + } + + // Check against max amount + const parsedAmount = parseUnits(customAmount, decimals) + const maxAmountBigInt = BigInt(maxAmount) + if (parsedAmount > maxAmountBigInt) { + return { type: 'error', message: 'Amount exceeds available balance' } + } + + return null + } catch (e) { + return { type: 'error', message: 'Invalid amount format' } + } + } + + const amountValidation = validateAmount() + + // Validate recipient + const getRecipientError = () => { + if (recipientType !== 'custom' || !customRecipient) return '' + if (!isAddress(customRecipient)) return 'Invalid address format' + return '' + } + + const recipientError = getRecipientError() + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!address) return + if (amountValidation || recipientError) return + + try { + setIsLoading(true) + const amount = amountType === 'max' ? BigInt(maxAmount) : parseUnits(customAmount, decimals) + const recipient = recipientType === 'self' ? address : (customRecipient as `0x${string}`) + + await forcedWithdrawal({ + args: [BigInt(lockId), recipient, amount] + }) + + showNotification({ + type: 'success', + title: 'Forced Withdrawal Executed', + message: `Successfully withdrew ${amountType === 'max' ? formattedMaxAmount : customAmount} ${symbol}`, + }) + + // Reset form and close + setAmountType('max') + setRecipientType('self') + setCustomAmount('') + setCustomRecipient('') + onClose() + } catch (error) { + console.error('Error executing forced withdrawal:', error) + showNotification({ + type: 'error', + title: 'Withdrawal Failed', + message: error instanceof Error ? error.message : 'Failed to execute withdrawal', + }) + } finally { + setIsLoading(false) + } + } + + if (!isOpen) return null + + return ( +
+
+

+ Execute Forced Withdrawal +

+
+
{tokenName} ({symbol})
+
Chain ID {targetChainId}
+
+ +
+
+ +
+ + {amountType === 'custom' && ( +
+ setCustomAmount(e.target.value)} + placeholder="0.0" + className={`w-full px-3 py-2 bg-gray-800 border ${ + amountValidation?.type === 'error' ? 'border-red-500' : 'border-gray-700' + } rounded-lg text-gray-300 focus:outline-none focus:border-[#00ff00] transition-colors`} + disabled={isLoading} + /> + {amountValidation && ( +

+ {amountValidation.message} +

+ )} +
+ )} +
+
+ +
+ +
+ + {recipientType === 'custom' && ( +
+ setCustomRecipient(e.target.value)} + placeholder="0x..." + className={`w-full px-3 py-2 bg-gray-800 border rounded-lg text-gray-300 focus:outline-none transition-colors ${recipientError ? 'border-red-500 focus:border-red-500' : 'border-gray-700 focus:border-[#00ff00]'}`} + disabled={isLoading} + /> + {recipientError && ( +
+ {recipientError} +
+ )} +
+ )} +
+
+ +
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/components/InitiateForcedWithdrawalDialog.tsx b/frontend/src/components/InitiateForcedWithdrawalDialog.tsx new file mode 100644 index 0000000..bbff3d4 --- /dev/null +++ b/frontend/src/components/InitiateForcedWithdrawalDialog.tsx @@ -0,0 +1,107 @@ +import { useState } from 'react' +import { useCompact } from '../hooks/useCompact' +import { useNotification } from '../context/NotificationContext' + +interface InitiateForcedWithdrawalDialogProps { + isOpen: boolean + onClose: () => void + lockId: string + resetPeriod: number +} + +export function InitiateForcedWithdrawalDialog({ + isOpen, + onClose, + lockId, + resetPeriod +}: InitiateForcedWithdrawalDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const { enableForcedWithdrawal } = useCompact() + const { showNotification } = useNotification() + + const handleInitiateWithdrawal = async () => { + if (isLoading) return + + try { + setIsLoading(true) + + await enableForcedWithdrawal({ + args: [BigInt(lockId)] + }) + + showNotification({ + type: 'success', + title: 'Forced Withdrawal Initiated', + message: 'The timelock period has started' + }) + + onClose() + } catch (error: any) { + console.error('Error initiating forced withdrawal:', error) + showNotification({ + type: 'error', + title: 'Transaction Failed', + message: error.message || 'Failed to initiate forced withdrawal' + }) + } finally { + setIsLoading(false) + } + } + + if (!isOpen) return null + + // Format reset period + const formatResetPeriod = (seconds: number): string => { + if (seconds < 60) return `${seconds} seconds`; + if (seconds < 3600) return `${Math.floor(seconds / 60)} minutes`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)} hours`; + return `${Math.floor(seconds / 86400)} days`; + }; + + return ( +
+
+

Initiate Forced Withdrawal

+

+ Are you sure you want to initiate a forced withdrawal? This will start a timelock period. +

+ +
+
+
+ + + +
+
+

Warning: Timelock Period

+
+

+ Initiating a forced withdrawal from this resource lock will start a timelock period lasting {formatResetPeriod(resetPeriod)}. You will need to wait for this period to end, + then submit another transaction to perform the forced withdrawal from this resource lock. To begin using this resource lock again, you must submit another transaction to disable forced withdrawals. +

+
+
+
+
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/components/Transfer.tsx b/frontend/src/components/Transfer.tsx new file mode 100644 index 0000000..946e266 --- /dev/null +++ b/frontend/src/components/Transfer.tsx @@ -0,0 +1,124 @@ +import { useState } from 'react' +import { useAccount, useChainId } from 'wagmi' +import { switchNetwork } from '@wagmi/core' +import { parseUnits, formatUnits, isAddress } from 'viem' +import { useNotification } from '../context/NotificationContext' +import { config } from '../config/wagmi' + +interface TransferProps { + allocatorAddress: string + chainId: string + resourceLockBalance: string + lockId: bigint + decimals: number + tokenName: { + resourceLockName: string + resourceLockSymbol: string + tokenName: string + } + tokenSymbol: string + resetPeriod: number + withdrawalStatus: number + withdrawableAt: string + onForceWithdraw: () => void + onDisableForceWithdraw: () => void +} + +export function Transfer({ + allocatorAddress, + chainId: targetChainId, + resourceLockBalance, + lockId, + decimals, + tokenName, + tokenSymbol, + resetPeriod, + withdrawalStatus, + withdrawableAt, + onForceWithdraw, + onDisableForceWithdraw +}: TransferProps) { + const { address } = useAccount() + const currentChainId = useChainId() + const [isOpen, setIsOpen] = useState(false) + const [isWithdrawal, setIsWithdrawal] = useState(false) + const { showNotification } = useNotification() + + const handleAction = async (action: 'transfer' | 'withdraw' | 'force' | 'disable') => { + // Check if we need to switch networks + const targetChainIdNumber = parseInt(targetChainId) + if (targetChainIdNumber !== currentChainId) { + try { + showNotification({ + type: 'success', + title: 'Switching Network', + message: `Switching to chain ID ${targetChainIdNumber}...` + }) + + await switchNetwork(config, { chainId: targetChainIdNumber }) + // Wait a bit for the network switch to complete + await new Promise(resolve => setTimeout(resolve, 1000)) + } catch (switchError) { + showNotification({ + type: 'error', + title: 'Network Switch Failed', + message: switchError instanceof Error ? switchError.message : 'Failed to switch network' + }) + return + } + } + + // Check if we have a valid address before proceeding + if (!address) { + showNotification({ + type: 'error', + title: 'Error', + message: 'Please connect your wallet first' + }) + return + } + + if (action === 'force') { + onForceWithdraw() + } else if (action === 'disable') { + onDisableForceWithdraw() + } else { + setIsWithdrawal(action === 'withdraw') + setIsOpen(true) + } + } + + return ( +
+
+ + + {withdrawalStatus === 0 ? ( + + ) : ( + + )} +
+
+ ) +} diff --git a/frontend/src/config/wagmi.ts b/frontend/src/config/wagmi.ts new file mode 100644 index 0000000..1b7146d --- /dev/null +++ b/frontend/src/config/wagmi.ts @@ -0,0 +1,28 @@ +import { http, createConfig } from 'wagmi' +import { type Chain } from 'viem' +import { mainnet, optimism, optimismGoerli, sepolia, goerli, base, baseSepolia } from 'viem/chains' +import { SUPPORTED_CHAINS } from '../constants/contracts' + +const chains: readonly [Chain, ...Chain[]] = [ + mainnet, + optimism, + optimismGoerli, + sepolia, + goerli, + base, + baseSepolia, +] as const + +// Create wagmi config with supported chains +export const config = createConfig({ + chains, + transports: { + [mainnet.id]: http(SUPPORTED_CHAINS[mainnet.id].rpcUrl), + [optimism.id]: http(SUPPORTED_CHAINS[optimism.id].rpcUrl), + [optimismGoerli.id]: http(SUPPORTED_CHAINS[optimismGoerli.id].rpcUrl), + [sepolia.id]: http(SUPPORTED_CHAINS[sepolia.id].rpcUrl), + [goerli.id]: http(SUPPORTED_CHAINS[goerli.id].rpcUrl), + [base.id]: http(SUPPORTED_CHAINS[base.id].rpcUrl), + [baseSepolia.id]: http(SUPPORTED_CHAINS[baseSepolia.id].rpcUrl), + } as const +}) diff --git a/frontend/src/constants/contracts.ts b/frontend/src/constants/contracts.ts index 3b40529..3b501cd 100644 --- a/frontend/src/constants/contracts.ts +++ b/frontend/src/constants/contracts.ts @@ -1,4 +1,4 @@ -import { mainnet, optimism, optimismGoerli, sepolia, goerli } from 'viem/chains' +import { mainnet, optimism, optimismGoerli, sepolia, goerli, base, baseSepolia } from 'viem/chains' // The Compact is deployed at the same address on all networks export const COMPACT_ADDRESS = '0x00000000000018df021ff2467df97ff846e09f48' as const @@ -35,6 +35,18 @@ export const SUPPORTED_CHAINS = { compactAddress: COMPACT_ADDRESS as `0x${string}`, blockExplorer: 'https://goerli.etherscan.io', }, + [base.id]: { + name: 'Base', + rpcUrl: 'https://base-mainnet.g.alchemy.com/v2/', + compactAddress: COMPACT_ADDRESS as `0x${string}`, + blockExplorer: 'https://basescan.org', + }, + [baseSepolia.id]: { + name: 'Base Sepolia', + rpcUrl: 'https://base-sepolia.g.alchemy.com/v2/', + compactAddress: COMPACT_ADDRESS as `0x${string}`, + blockExplorer: 'https://sepolia.basescan.org', + }, } as const export const COMPACT_ABI = [ @@ -58,6 +70,43 @@ export const COMPACT_ABI = [ stateMutability: 'nonpayable', type: 'function', }, + // Forced withdrawal functions + { + inputs: [{ name: 'id', type: 'uint256' }], + name: 'enableForcedWithdrawal', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [{ name: 'id', type: 'uint256' }], + name: 'disableForcedWithdrawal', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + { + inputs: [ + { name: 'id', type: 'uint256' }, + { name: 'recipient', type: 'address' }, + { name: 'amount', type: 'uint256' }, + ], + name: 'forcedWithdrawal', + outputs: [], + stateMutability: 'nonpayable', + type: 'function', + }, + // Nonce consumption check + { + inputs: [ + { name: 'nonce', type: 'uint256' }, + { name: 'allocator', type: 'address' }, + ], + name: 'hasConsumedAllocatorNonce', + outputs: [{ name: '', type: 'bool' }], + stateMutability: 'view', + type: 'function', + }, ] as const export const ERC20_ABI = [ diff --git a/frontend/src/hooks/useBalances.ts b/frontend/src/hooks/useBalances.ts index 8e8e063..e1dbf17 100644 --- a/frontend/src/hooks/useBalances.ts +++ b/frontend/src/hooks/useBalances.ts @@ -22,6 +22,7 @@ interface Balance { allocatedBalance: string balanceAvailableToAllocate: string withdrawalStatus: number + withdrawableAt: string // Token details from indexer token?: Token // Resource lock details from indexer diff --git a/frontend/src/hooks/useCompact.ts b/frontend/src/hooks/useCompact.ts index 1615c39..a8d4a2b 100644 --- a/frontend/src/hooks/useCompact.ts +++ b/frontend/src/hooks/useCompact.ts @@ -17,6 +17,10 @@ interface TokenDeposit { type DepositParams = NativeDeposit | TokenDeposit +interface WithdrawalParams { + args: readonly [bigint] | readonly [bigint, `0x${string}`, bigint] +} + export function useCompact() { const chainId = useChainId() const { writeContract } = useWriteContract() @@ -56,8 +60,71 @@ export function useCompact() { } } + const enableForcedWithdrawal = async (params: WithdrawalParams) => { + if (!isSupportedChain(chainId)) { + throw new Error('Unsupported chain') + } + + try { + const hash = await writeContract({ + address: COMPACT_ADDRESS as `0x${string}`, + abi: COMPACT_ABI, + functionName: 'enableForcedWithdrawal', + args: params.args as [bigint], + }) + + return hash + } catch (error) { + console.error('Enable forced withdrawal error:', error) + throw error + } + } + + const disableForcedWithdrawal = async (params: WithdrawalParams) => { + if (!isSupportedChain(chainId)) { + throw new Error('Unsupported chain') + } + + try { + const hash = await writeContract({ + address: COMPACT_ADDRESS as `0x${string}`, + abi: COMPACT_ABI, + functionName: 'disableForcedWithdrawal', + args: params.args as [bigint], + }) + + return hash + } catch (error) { + console.error('Disable forced withdrawal error:', error) + throw error + } + } + + const forcedWithdrawal = async (params: WithdrawalParams) => { + if (!isSupportedChain(chainId)) { + throw new Error('Unsupported chain') + } + + try { + const hash = await writeContract({ + address: COMPACT_ADDRESS as `0x${string}`, + abi: COMPACT_ABI, + functionName: 'forcedWithdrawal', + args: params.args as [bigint, `0x${string}`, bigint], + }) + + return hash + } catch (error) { + console.error('Forced withdrawal error:', error) + throw error + } + } + return { deposit, + enableForcedWithdrawal, + disableForcedWithdrawal, + forcedWithdrawal, isSupported: isSupportedChain(chainId), } }