diff --git a/.changeset/hungry-bananas-join.md b/.changeset/hungry-bananas-join.md new file mode 100644 index 000000000..c115cd0e4 --- /dev/null +++ b/.changeset/hungry-bananas-join.md @@ -0,0 +1,6 @@ +--- +"@status-im/status-network": patch +"hub": patch +--- + +withdraw diff --git a/apps/hub/src/app/_components/emergency-bar.tsx b/apps/hub/src/app/_components/emergency-bar.tsx new file mode 100644 index 000000000..3233a008e --- /dev/null +++ b/apps/hub/src/app/_components/emergency-bar.tsx @@ -0,0 +1,24 @@ +import { AlertIcon } from '@status-im/icons/20' +import { ButtonLink } from '@status-im/status-network/components' + +const EmergencyBar = () => { + return ( +
+
+

+ Contracts have been compromised. +

+ } + href="/stake" + > + Withdraw funds + +
+
+ ) +} + +export { EmergencyBar } diff --git a/apps/hub/src/app/_components/hub-layout.tsx b/apps/hub/src/app/_components/hub-layout.tsx index 0da89edee..04829fd79 100644 --- a/apps/hub/src/app/_components/hub-layout.tsx +++ b/apps/hub/src/app/_components/hub-layout.tsx @@ -2,7 +2,10 @@ import { useState } from 'react' import { Divider, Footer } from '@status-im/status-network/components' +import { useReadContract } from 'wagmi' +import { STAKING_MANAGER } from '../_constants/address' +import { EmergencyBar } from './emergency-bar' import { Sidebar } from './sidebar' import { TopBar } from './top-bar' @@ -12,6 +15,14 @@ interface HubLayoutProps { export function HubLayout({ children }: HubLayoutProps) { const [sidebarOpen, setSidebarOpen] = useState(false) + const { data: emergencyModeEnabled } = useReadContract({ + address: STAKING_MANAGER.address, + abi: STAKING_MANAGER.abi, + functionName: 'emergencyModeEnabled', + query: { + refetchInterval: 30000, + }, + }) return (
@@ -20,6 +31,7 @@ export function HubLayout({ children }: HubLayoutProps) { {/* Main Content Area */}
+ {Boolean(emergencyModeEnabled) && }
{/* Sidebar */} setSidebarOpen(false)} /> diff --git a/apps/hub/src/app/_components/vaults/modals/withdraw-vault-modal.tsx b/apps/hub/src/app/_components/vaults/modals/withdraw-vault-modal.tsx index 5258ffd4a..e281c29f4 100644 --- a/apps/hub/src/app/_components/vaults/modals/withdraw-vault-modal.tsx +++ b/apps/hub/src/app/_components/vaults/modals/withdraw-vault-modal.tsx @@ -7,7 +7,7 @@ import { InfoIcon } from '@status-im/icons/12' import { Button } from '@status-im/status-network/components' import { useAccount } from 'wagmi' -import { useVaultWithdraw } from '~hooks/useVaultWithdraw' +import { useVaultEmergencyExit } from '~hooks/useVaultEmergencyExit' import { BaseVaultModal } from './base-vault-modal' @@ -16,6 +16,7 @@ import type { Address } from 'viem' interface WithdrawVaultModalProps { onClose: () => void vaultAddress: Address + amountWei: bigint open?: boolean onOpenChange?: (open: boolean) => void children?: React.ReactNode @@ -25,21 +26,20 @@ interface WithdrawVaultModalProps { * Modal for emergency withdrawal from vault */ export function WithdrawVaultModal(props: WithdrawVaultModalProps) { - const { onClose, vaultAddress, open, onOpenChange, children } = props + const { onClose, vaultAddress, amountWei, open, onOpenChange, children } = + props const { address } = useAccount() - const { mutate: withdraw } = useVaultWithdraw() + const { mutate: emergencyExit } = useVaultEmergencyExit() const handleVaultWithdrawal = useCallback(() => { - const amountWei = 1000000000000000000n - if (!address) { console.error('No address found - wallet not connected') return } try { - withdraw({ + emergencyExit({ amountWei, vaultAddress, onSigned: () => { @@ -47,9 +47,9 @@ export function WithdrawVaultModal(props: WithdrawVaultModalProps) { }, }) } catch (error) { - console.error('Error calling withdraw:', error) + console.error('Error calling emergencyExit:', error) } - }, [address, onClose, vaultAddress, withdraw]) + }, [address, amountWei, onClose, vaultAddress, emergencyExit]) return ( { +const calculateTotalStaked = ( + vaults: StakingVault[], + emergencyMode: boolean +): bigint => { return vaults.reduce( - (acc, vault) => acc + (vault.data?.stakedBalance || 0n), + (acc, vault) => + acc + + (emergencyMode + ? vault.data?.depositedBalance || 0n + : vault.data?.stakedBalance || 0n), BigInt(0) ) } @@ -90,7 +97,10 @@ export const createVaultTableColumns = ({ chainId, }: TableColumnsProps) => { // Calculate totals and current time once per column creation - const totalStaked = calculateTotalStaked(vaults) + const totalStaked = calculateTotalStaked( + vaults, + Boolean(emergencyModeEnabled) + ) const totalKarma = calculateTotalKarma(vaults) const currentTimestamp = getCurrentTimestamp() const columnHelper = createColumnHelper() @@ -131,12 +141,15 @@ export const createVaultTableColumns = ({ }, }), columnHelper.accessor('data.stakedBalance', { - header: 'Staked', + header: emergencyModeEnabled ? 'Vault balance' : 'Staked', cell: ({ row }) => { + const balance = emergencyModeEnabled + ? row.original.data?.depositedBalance + : row.original.data?.stakedBalance return (
- {formatSNT(row.original.data?.stakedBalance || 0n)} + {formatSNT(balance || 0n)} SNT
@@ -290,86 +303,87 @@ export const createVaultTableColumns = ({ : false return ( -
- {isLocked ? ( -
- {!emergencyModeEnabled && ( - + {emergencyModeEnabled ? ( + + setOpenModalVaultId(open ? withdrawModalId : null) + } + onClose={() => setOpenModalVaultId(null)} + vaultAddress={row.original.address} + amountWei={row.original.data?.depositedBalance || 0n} + > + + + ) : ( + <> + {isLocked ? ( +
+ + setOpenModalVaultId(open ? lockModalId : null) + } + vaultAddress={row.original.address} + title="Extend lock time" + initialYears="2" + initialDays="732" + description="Extending lock time increasing Karma boost" + actions={[...EXTEND_LOCK_ACTIONS]} + onClose={() => setOpenModalVaultId(null)} + infoMessage={LOCK_INFO_MESSAGE} + > + + +
+ ) : ( + - setOpenModalVaultId(open ? withdrawModalId : null) + setOpenModalVaultId(open ? lockModalId : null) } - onClose={() => setOpenModalVaultId(null)} vaultAddress={row.original.address} + title="Do you want to lock the vault?" + description="Lock this vault to receive more Karma" + actions={[...LOCK_VAULT_ACTIONS]} + onClose={() => setOpenModalVaultId(null)} + infoMessage={LOCK_INFO_MESSAGE} + onValidate={validateLockTime} > -
+ )} - - setOpenModalVaultId(open ? lockModalId : null) - } - vaultAddress={row.original.address} - title="Extend lock time" - initialYears="2" - initialDays="732" - description="Extending lock time increasing Karma boost" - actions={[...EXTEND_LOCK_ACTIONS]} - onClose={() => setOpenModalVaultId(null)} - infoMessage={LOCK_INFO_MESSAGE} - > - - -
- ) : ( - - setOpenModalVaultId(open ? lockModalId : null) - } - vaultAddress={row.original.address} - title="Do you want to lock the vault?" - description="Lock this vault to receive more Karma" - actions={[...LOCK_VAULT_ACTIONS]} - onClose={() => setOpenModalVaultId(null)} - infoMessage={LOCK_INFO_MESSAGE} - onValidate={validateLockTime} - > - - + )} setOpenDropdownId(open ? dropdownId : null)} diff --git a/apps/hub/src/app/_components/vaults/vaults-table.tsx b/apps/hub/src/app/_components/vaults/vaults-table.tsx index 9113dccb1..06bb5a818 100644 --- a/apps/hub/src/app/_components/vaults/vaults-table.tsx +++ b/apps/hub/src/app/_components/vaults/vaults-table.tsx @@ -133,7 +133,7 @@ export function VaultsTable() { abi: STAKING_MANAGER.abi, functionName: 'emergencyModeEnabled', query: { - staleTime: 60_000, // Consider data fresh for 1 minute + refetchInterval: 30000, }, }) @@ -186,6 +186,7 @@ export function VaultsTable() { size="32" onClick={() => createVault()} className="w-full sm:w-auto" + disabled={Boolean(emergencyModeEnabled)} > Add vault diff --git a/apps/hub/src/app/_constants/address.ts b/apps/hub/src/app/_constants/address.ts index 20a81cee3..8ceaea30c 100644 --- a/apps/hub/src/app/_constants/address.ts +++ b/apps/hub/src/app/_constants/address.ts @@ -8,12 +8,12 @@ import { import type { Abi, Address } from 'viem' export const STAKING_MANAGER = { - address: '0x5cDf1646E4c1D21eE94DED1DA8da3Ca450dc96D1' as Address, + address: '0x07301236DDAD37dCA93690e7a7049Bc13F55158E' as Address, abi: stakingManagerAbi as Abi, } as const export const VAULT_FACTORY = { - address: '0xddDcd43a0B0dA865decf3e4Ae71FbBE3e2DfFF14' as Address, + address: '0x489427Fad204FF494Cd8BE860D4af76b4Ce9F717' as Address, abi: vaultFactoryAbi as Abi, } as const diff --git a/apps/hub/src/app/_hooks/useStakingVaults.ts b/apps/hub/src/app/_hooks/useStakingVaults.ts index 09307efe1..8cc0a6237 100644 --- a/apps/hub/src/app/_hooks/useStakingVaults.ts +++ b/apps/hub/src/app/_hooks/useStakingVaults.ts @@ -4,7 +4,7 @@ import { useAccount, useChainId, useConfig } from 'wagmi' import { readContract, readContracts } from 'wagmi/actions' import { vaultAbi } from '~constants/contracts' -import { CACHE_CONFIG, STAKING_MANAGER } from '~constants/index' +import { CACHE_CONFIG, SNT_TOKEN, STAKING_MANAGER } from '~constants/index' // ============================================================================ // Types @@ -29,6 +29,8 @@ export interface StakingVaultData { lockUntil: bigint /** Total rewards accrued and available for claiming */ rewardsAccrued: bigint + /** Actual token balance in the vault (from balanceOf) - reliable in emergency mode */ + depositedBalance: bigint } /** @@ -112,32 +114,47 @@ async function fetchVaultData( functionName: 'lockUntil', args: [], }, + { + chainId, + address: SNT_TOKEN.address, + abi: SNT_TOKEN.abi, + functionName: 'balanceOf', + args: [vaultAddress], + }, ], }) - // Check if both contract calls succeeded - const [vaultResult, lockUntilResult] = results + // Check if all contract calls succeeded + const [vaultResult, lockUntilResult, depositedBalanceResult] = results if ( vaultResult.status !== 'success' || - lockUntilResult.status !== 'success' + lockUntilResult.status !== 'success' || + depositedBalanceResult.status !== 'success' ) { console.error( `Failed to fetch vault data for ${vaultAddress}:`, vaultResult.status !== 'success' ? vaultResult.error - : lockUntilResult.error + : lockUntilResult.status !== 'success' + ? lockUntilResult.error + : depositedBalanceResult.error ) return null } // Extract the actual data from successful results - const vaultData = vaultResult.result as Omit + const vaultData = vaultResult.result as Omit< + StakingVaultData, + 'lockUntil' | 'depositedBalance' + > const lockUntil = lockUntilResult.result as bigint + const depositedBalance = depositedBalanceResult.result as bigint return { ...vaultData, lockUntil, + depositedBalance, } } catch (error) { // Log error for debugging but don't throw - allows partial results diff --git a/apps/hub/src/app/_hooks/useVaultEmergencyExit.ts b/apps/hub/src/app/_hooks/useVaultEmergencyExit.ts new file mode 100644 index 000000000..071aacdba --- /dev/null +++ b/apps/hub/src/app/_hooks/useVaultEmergencyExit.ts @@ -0,0 +1,165 @@ +import { + useMutation, + type UseMutationResult, + useQueryClient, +} from '@tanstack/react-query' +import { type Address, formatUnits } from 'viem' +import { useAccount, useConfig, useWriteContract } from 'wagmi' +import { waitForTransactionReceipt } from 'wagmi/actions' + +import { vaultAbi } from '~constants/contracts' +import { SNT_TOKEN, testnet } from '~constants/index' +import { useVaultStateContext } from '~hooks/useVaultStateContext' + +// ============================================================================ +// Types +// ============================================================================ + +/** + * Parameters for emergency exit from a vault + */ +export interface EmergencyExitParams { + /** Amount being withdrawn (for display purposes only) */ + amountWei: bigint + /** Vault address */ + vaultAddress: Address + /** Optional callback called immediately after user signs transaction */ + onSigned?: () => void +} + +/** + * Return type for the useVaultEmergencyExit hook + */ +export type UseVaultEmergencyExitReturn = UseMutationResult< + void, + Error, + EmergencyExitParams, + unknown +> + +// ============================================================================ +// Constants +// ============================================================================ + +const MUTATION_KEY_PREFIX = 'vault-emergency-exit' as const +const CONFIRMATION_BLOCKS = 1 + +// ============================================================================ +// Mutation Hook +// ============================================================================ + +/** + * Mutation hook for emergency exit from a vault + * + * Performs emergency withdrawal from a vault contract when emergency mode is enabled. + * This withdraws ALL staked tokens from the vault to the connected wallet address. + * Manages the state machine transitions for the withdrawal process. + * + * **Process Flow:** + * 1. Validates wallet connection + * 2. Calls vault.emergencyExit(address) and waits for user to sign + * 3. After signing, sends START_WITHDRAW event → Goes directly to processing state + * 4. Calls onSigned callback (typically to close modal) + * 5. Waits for transaction confirmation + * 6. On success: Refetches data and resets state machine + * 7. On error: Sends REJECT event → Shows rejected state + * + * **Important:** emergencyExit withdraws ALL funds from the vault regardless of + * the amountWei parameter. The amount is only used for display purposes in the UI. + * + * @returns Mutation result with mutate function to trigger emergency withdrawal + * + * @throws {Error} When wallet is not connected + * @throws {Error} When transaction is reverted + * + * @example + * Basic usage with modal closing after sign + * ```tsx + * function EmergencyWithdrawModal({ vaultAddress, stakedAmount, onClose }: Props) { + * const { mutate: emergencyExit } = useVaultEmergencyExit() + * + * const handleEmergencyExit = () => { + * emergencyExit({ + * amountWei: stakedAmount, + * vaultAddress, + * onSigned: () => { + * // Close modal after user signs in wallet + * onClose() + * } + * }) + * } + * + * return + * } + * ``` + */ +export function useVaultEmergencyExit(): UseVaultEmergencyExitReturn { + const { address } = useAccount() + const { writeContractAsync } = useWriteContract() + const config = useConfig() + const queryClient = useQueryClient() + const { send: sendVaultEvent, reset: resetVault } = useVaultStateContext() + + return useMutation({ + mutationKey: [MUTATION_KEY_PREFIX, address], + mutationFn: async ({ + amountWei, + vaultAddress, + onSigned, + }: EmergencyExitParams): Promise => { + // Validate wallet connection + if (!address) { + throw new Error( + 'Wallet not connected. Please connect your wallet first.' + ) + } + + // Send START_WITHDRAW event first to transition state machine to processing + sendVaultEvent({ + type: 'START_WITHDRAW', + amount: formatUnits(amountWei, SNT_TOKEN.decimals), + }) + + // Close the modal immediately so the status dialog can show + onSigned?.() + + try { + // Execute emergency exit transaction + // emergencyExit withdraws ALL funds to the specified destination address + const hash = await writeContractAsync({ + chain: testnet, + account: address, + address: vaultAddress, + abi: vaultAbi, + functionName: 'emergencyExit', + args: [address], + }) + + // Wait for transaction confirmation + const { status } = await waitForTransactionReceipt(config, { + hash, + confirmations: CONFIRMATION_BLOCKS, + }) + + // Check if transaction was reverted + if (status === 'reverted') { + sendVaultEvent({ type: 'REJECT' }) + throw new Error('Transaction was reverted') + } + + // Transaction successful, invalidate cache to force fresh data from blockchain + await queryClient.invalidateQueries({ + queryKey: ['staking-vaults'], + }) + await queryClient.invalidateQueries({ + queryKey: ['multiplier-points-balance'], + }) + resetVault() + } catch (error) { + // Transaction failed or user rejected + sendVaultEvent({ type: 'REJECT' }) + throw error + } + }, + }) +} diff --git a/apps/hub/src/app/stake/page.tsx b/apps/hub/src/app/stake/page.tsx index f3efaf4f8..fdef84dcf 100644 --- a/apps/hub/src/app/stake/page.tsx +++ b/apps/hub/src/app/stake/page.tsx @@ -74,6 +74,15 @@ export default function StakePage() { const weightedBoost = useWeightedBoost(vaults) const { data: exchangeRate } = useExchangeRate() + const { data: emergencyModeEnabled } = useReadContract({ + address: STAKING_MANAGER.address, + abi: STAKING_MANAGER.abi, + functionName: 'emergencyModeEnabled', + query: { + refetchInterval: 30000, + }, + }) + const form = useForm({ resolver: zodResolver(createStakeFormSchema()), mode: 'onChange', @@ -265,7 +274,10 @@ export default function StakePage() { ) : ( @@ -435,24 +452,27 @@ export default function StakePage() {
-
-
-

- Total staked + {!emergencyModeEnabled && ( +

+
+

+ Total staked +

+
+
+ + + {formatSNT(totalStaked ?? 0, { + includeSymbol: true, + })} + +
+

+ Next unlock in {STAKE_PAGE_CONSTANTS.NEXT_UNLOCK_DAYS}{' '} + days

-
- - - {formatSNT(totalStaked ?? 0, { - includeSymbol: true, - })} - -
-

- Next unlock in {STAKE_PAGE_CONSTANTS.NEXT_UNLOCK_DAYS} days -

-
+ )}
@@ -472,7 +492,10 @@ export default function StakePage() { {messageMultiplierPoints}