diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsStakerBalance.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsStakerBalance.tsx index 032a98b49..05dd0339e 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsStakerBalance.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsStakerBalance.tsx @@ -1,46 +1,45 @@ -import { useEffect } from "react" import { useAccount } from "wagmi" import { formatTokenAmount } from "@/utils/atpFormatters" import { useStakingAssetTokenDetails } from "@/hooks/stakingRegistry" -import { useStakerBalance, useMoveFundsBackToATP } from "@/hooks/staker" +import { useStakerBalance } from "@/hooks/staker" import { TooltipIcon } from "@/components/Tooltip" -import { useStakeableAmount, type ATPData } from "@/hooks" -import { useQueryClient } from "@tanstack/react-query" +import { Icon } from "@/components/Icon" +import { type ATPData } from "@/hooks" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { buildMoveFundsBackToATPEntry } from "@/utils/actionCart" interface ATPDetailsStakerBalanceProps { atp: ATPData } /** - * Displays staker contract balance and provides button to move funds back to ATP - * Only the operator can move funds back to vault - * Compact inline layout + * Displays staker contract balance and a button to queue a move-funds-back-to-ATP + * transaction in the cart. Only the operator can submit it on-chain. + * + * Compact inline layout. */ export const ATPDetailsStakerBalance = ({ atp }: ATPDetailsStakerBalanceProps) => { const { address: connectedAddress } = useAccount() - const { balance, isLoading: isLoadingBalance, refetch } = useStakerBalance({ stakerAddress: atp.staker }) + const { balance, isLoading: isLoadingBalance } = useStakerBalance({ stakerAddress: atp.staker }) const { symbol, decimals, isLoading: isLoadingTokenDetails } = useStakingAssetTokenDetails() - const { moveFunds, isPending, isConfirming, isSuccess } = useMoveFundsBackToATP(atp.staker!) - const { refetch: refetchStakeableAmount } = useStakeableAmount(atp) - const queryClient = useQueryClient() + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart() const isLoading = isLoadingBalance || isLoadingTokenDetails - const isProcessing = isPending || isConfirming const hasBalance = balance > 0n const isOperator = connectedAddress?.toLowerCase() === atp.operator?.toLowerCase() - // Refetch balance after successful move - useEffect(() => { - if (isSuccess) { - refetch() - refetchStakeableAmount() + const entry = atp.staker + ? buildMoveFundsBackToATPEntry({ stakerAddress: atp.staker, atpAddress: atp.atpAddress }) + : undefined - // Invalidate multiple stakeable amounts and refetch - queryClient.invalidateQueries({ - queryKey: ['readContracts'] - }) - } - }, [isSuccess, refetch]) + const isQueued = !!entry && !!entry.metadata?.stepType && !!entry.metadata?.stepGroupIdentifier && + checkStepGroupInQueue(entry.metadata.stepType, entry.metadata.stepGroupIdentifier) + + const handleAddToBatch = () => { + if (!entry) return + addTransaction(entry, { preventDuplicate: true }) + openCart() + } return (
@@ -59,13 +58,24 @@ export const ATPDetailsStakerBalance = ({ atp }: ATPDetailsStakerBalanceProps) =
{hasBalance && (
- + {isQueued ? ( + + ) : ( + + )} {!isOperator && ( { const [selectedVersion, setSelectedVersion] = useState(null) const [isOpen, setIsOpen] = useState(false) + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart() // Get current implementation const { implementation: currentImplementation, isLoading: isLoadingImplementation, - refetch: refetchImplementation } = useStakerImplementationFromStaker(atp.staker as Address) // Get available versions @@ -44,9 +45,6 @@ export const ATPDetailsStakerManagement = ({ atp }: ATPDetailsStakerManagementPr }) const { implementations, isLoading: isLoadingImplementations } = useStakerImplementations(stakerVersions, atp.registry) - // Staker operations - const upgradeStakerHook = useUpgradeStaker(atp.atpAddress as Address) - // Get current version number const currentVersion = useMemo(() => { return getVersionByImplementation(currentImplementation, implementations) @@ -67,24 +65,23 @@ export const ATPDetailsStakerManagement = ({ atp }: ATPDetailsStakerManagementPr } }, [selectedVersion, currentVersion, stakerVersions, isLoadingImplementation]) - // Refetch implementation after successful upgrade - useEffect(() => { - if (upgradeStakerHook.isSuccess) { - refetchImplementation() - } - }, [upgradeStakerHook.isSuccess, refetchImplementation]) + const upgradeEntry = useMemo(() => { + if (!selectedVersion) return undefined + return buildUpgradeStakerEntry({ atpAddress: atp.atpAddress as Address, version: selectedVersion }) + }, [selectedVersion, atp.atpAddress]) + + const isUpgradeQueued = !!upgradeEntry && !!upgradeEntry.metadata?.stepType && + !!upgradeEntry.metadata?.stepGroupIdentifier && + checkStepGroupInQueue(upgradeEntry.metadata.stepType, upgradeEntry.metadata.stepGroupIdentifier) const handleVersionChange = (value: string) => { setSelectedVersion(BigInt(value)) } - const handleUpgrade = async () => { - if (!selectedVersion) return - try { - await upgradeStakerHook.upgradeStaker(selectedVersion) - } catch (error) { - console.error('Failed to upgrade staker:', error) - } + const handleUpgrade = () => { + if (!upgradeEntry) return + addTransaction(upgradeEntry, { preventDuplicate: true }) + openCart() } const isLoading = isLoadingImplementation || isLoadingImplementations @@ -211,7 +208,6 @@ export const ATPDetailsStakerManagement = ({ atp }: ATPDetailsStakerManagementPr
{canUpgrade && ( - + isUpgradeQueued ? ( + + ) : ( + + ) )} - - {upgradeStakerHook.error && ( -
- {upgradeStakerHook.error.message.includes('rejected') ? 'Transaction cancelled' : 'Upgrade failed'} -
- )} - - {upgradeStakerHook.isSuccess && ( -
- Successfully upgraded to v{selectedVersion?.toString()} -
- )} )} diff --git a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsTechnicalInfo.tsx b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsTechnicalInfo.tsx index 1c3d4a25a..344e7a908 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsTechnicalInfo.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/ATPDetailsTechnicalInfo.tsx @@ -1,16 +1,18 @@ -import { useState, useMemo, useEffect } from "react" +import { useState, useMemo } from "react" import { Icon } from "@/components/Icon" import { useAtpRegistryData, useStakerImplementations } from "@/hooks/atpRegistry" import { useStakerImplementation as useStakerImplementationFromStaker } from "@/hooks/staker/useStakerImplementation" -import { useUpgradeStaker } from "@/hooks/atp" import { AddressDisplay } from "@/components/AddressDisplay" import { TooltipIcon } from "@/components/Tooltip" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { buildUpgradeStakerEntry } from "@/utils/actionCart" import { getVersionByImplementation, getImplementationDescription } from "@/utils/stakerVersion" import type { ATPData } from "@/hooks/atp" import type { Address } from "viem" interface ATPDetailsTechnicalInfoProps { atp: ATPData + // Kept for source-compatibility; cart execution drives refetch globally. onUpgradeSuccess?: () => void } @@ -18,10 +20,11 @@ interface ATPDetailsTechnicalInfoProps { * Component displaying technical details of a Token Vault position * Shows vault address, and staker information if staker contract exists */ -export const ATPDetailsTechnicalInfo = ({ atp, onUpgradeSuccess }: ATPDetailsTechnicalInfoProps) => { +export const ATPDetailsTechnicalInfo = ({ atp }: ATPDetailsTechnicalInfoProps) => { const [isTechnicalDetailsExpanded, setIsTechnicalDetailsExpanded] = useState(true) + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart() - const { implementation: stakerImplementation, isLoading: isLoadingImplementation, refetch } = useStakerImplementationFromStaker( + const { implementation: stakerImplementation, isLoading: isLoadingImplementation } = useStakerImplementationFromStaker( atp.staker as Address ) @@ -29,7 +32,6 @@ export const ATPDetailsTechnicalInfo = ({ atp, onUpgradeSuccess }: ATPDetailsTec registryAddress: atp.registry }) const { implementations, isLoading: isLoadingImplementations } = useStakerImplementations(stakerVersions, atp.registry) - const upgradeStakerHook = useUpgradeStaker(atp.atpAddress as Address) const stakerVersion = useMemo(() => { return getVersionByImplementation(stakerImplementation, implementations) @@ -47,23 +49,22 @@ export const ATPDetailsTechnicalInfo = ({ atp, onUpgradeSuccess }: ATPDetailsTec return getImplementationDescription(stakerImplementation, stakerVersion!) }, [stakerImplementation, stakerVersion]) - useEffect(() => { - if (upgradeStakerHook.isSuccess) { - refetch() - onUpgradeSuccess?.() - } - }, [upgradeStakerHook.isSuccess, refetch, onUpgradeSuccess]) - const isOnLatestVersion = stakerVersion !== null && latestVersion !== null && stakerVersion === latestVersion const isLoadingVersion = isLoadingImplementation || isLoadingImplementations - const handleUpgrade = async () => { - if (!latestVersion) return - try { - await upgradeStakerHook.upgradeStaker(latestVersion) - } catch (error) { - console.error('Failed to upgrade staker:', error) - } + const upgradeEntry = useMemo(() => { + if (!latestVersion) return undefined + return buildUpgradeStakerEntry({ atpAddress: atp.atpAddress as Address, version: latestVersion }) + }, [latestVersion, atp.atpAddress]) + + const isUpgradeQueued = !!upgradeEntry && !!upgradeEntry.metadata?.stepType && + !!upgradeEntry.metadata?.stepGroupIdentifier && + checkStepGroupInQueue(upgradeEntry.metadata.stepType, upgradeEntry.metadata.stepGroupIdentifier) + + const handleUpgrade = () => { + if (!upgradeEntry) return + addTransaction(upgradeEntry, { preventDuplicate: true }) + openCart() } return ( @@ -130,30 +131,26 @@ export const ATPDetailsTechnicalInfo = ({ atp, onUpgradeSuccess }: ATPDetailsTec ) : ( <> - + {isUpgradeQueued ? ( + + ) : ( + + )}
{currentDescription}
)} - {upgradeStakerHook.error && ( -
- {upgradeStakerHook.error.message.includes('rejected') ? 'Transaction cancelled' : 'Upgrade failed'} -
- )} - {upgradeStakerHook.isSuccess && ( -
- Successfully upgraded to latest version -
- )} )} diff --git a/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx b/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx index 251c3c692..7f9b8f007 100644 --- a/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx +++ b/staking-dashboard/src/components/ATPDetailsModal/WithdrawalActions.tsx @@ -1,75 +1,15 @@ -import { useEffect } from "react"; import type { Address } from "viem"; import { useInitiateWithdraw } from "@/hooks/staker/useInitiateWithdraw"; -import { useFinalizeWithdraw } from "@/hooks/rollup/useFinalizeWithdraw"; import { TooltipIcon } from "@/components/Tooltip"; +import { Icon } from "@/components/Icon"; import { SequencerStatus } from "@/hooks/rollup/useSequencerStatus"; -import { useAlert } from "@/contexts/AlertContext"; +import { useTransactionCart } from "@/contexts/TransactionCartContext"; import { getUnlockTimeDisplay } from "@/utils/dateFormatters"; import { MilestoneStatusBadge } from "@/components/MilestoneStatusBadge"; - -/** - * Parse contract errors to extract user-friendly messages - * Contract errors are often buried in the error object or masked by nonce errors - */ -function parseContractError(error: Error): string { - const message = error.message || ""; - - // Known contract error signatures and their user-friendly messages - const errorMappings: Record = { - "Staking__NotExiting": "Sequencer is not in exiting state. Initiate unstake first.", - "Staking__ExitDelayNotPassed": "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", - "Staking__WithdrawalDelayNotPassed": "Withdrawal delay has not passed yet. Please wait for the withdrawal period to complete.", - "NotExiting": "Sequencer is not in exiting state.", - "ExitDelayNotPassed": "Exit delay has not passed yet.", - "0xef566ee0": "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", // Staking__NotExiting selector - }; - - // Check for known error patterns - for (const [pattern, friendlyMessage] of Object.entries(errorMappings)) { - if (message.includes(pattern)) { - return friendlyMessage; - } - } - - // Check for reverted errors that contain the actual reason - const revertMatch = message.match(/reverted with.*?["']([^"']+)["']/i); - if (revertMatch) { - return revertMatch[1]; - } - - // Check for custom error data in the message - const customErrorMatch = message.match(/error=\{[^}]*"data":"(0x[a-f0-9]+)"/i); - if (customErrorMatch) { - const errorData = customErrorMatch[1]; - // Check if this matches a known error selector - for (const [selector, friendlyMessage] of Object.entries(errorMappings)) { - if (errorData.startsWith(selector)) { - return friendlyMessage; - } - } - } - - // If we see nonce errors but there's also contract error data, the contract error is the real issue - if (message.includes("nonce") && message.includes("0x")) { - // Try to find error selector in the message - const selectorMatch = message.match(/0x[a-f0-9]{8}/i); - if (selectorMatch) { - const selector = selectorMatch[0].toLowerCase(); - if (selector === "0xef566ee0") { - return "Exit delay has not passed yet. Please wait for the withdrawal period to complete."; - } - } - return "Transaction failed. The contract rejected the call - please check that all conditions are met."; - } - - // Return original message if no pattern matched (but truncate if too long) - if (message.length > 200) { - return message.substring(0, 200) + "..."; - } - - return message || "Transaction failed"; -} +import { + buildStakerInitiateWithdrawEntry, + buildRollupFinalizeWithdrawEntry, +} from "@/utils/unstakeCart"; interface WithdrawalActionsProps { stakerAddress: Address; @@ -85,11 +25,16 @@ interface WithdrawalActionsProps { atpType?: string; registryAddress?: Address; milestoneId?: bigint; + providerName?: string | null; } /** - * Component for withdrawal and unstake actions - * Displays initiate unstake and finalize withdraw buttons with proper state management + * Initiate / finalize unstake actions. Queues each as an `unstake` cart entry + * so Safe wallets batch them into a single proposal alongside any claims that + * happen to be in the same cart. EOA wallets get a sequential prompt per + * entry (unstake is `msg.sender`-bound and can't ride through Multicall3), + * which matches the prior immediate-tx UX from the user's perspective but + * persists across page reloads via the cart's localStorage state. */ export const WithdrawalActions = ({ stakerAddress, @@ -101,99 +46,79 @@ export const WithdrawalActions = ({ actualUnlockTime, withdrawalDelayDays, onSuccess, - // ATP context atpType, registryAddress, milestoneId, + providerName, }: WithdrawalActionsProps) => { - const { showAlert } = useAlert(); const isExiting = status === SequencerStatus.EXITING; - const { - initiateWithdraw, - isPending: isInitiatingWithdraw, - isConfirming: isConfirmingInitiate, - isSuccess: isInitiateSuccess, - error: initiateError, - milestoneStatus, - isMilestoneLoading, - canWithdraw, - milestoneBlockError, - } = useInitiateWithdraw(stakerAddress, { - registryAddress, - milestoneId, - atpType, - }); + // We only consume the milestone-status read from this hook now; the + // immediate `initiateWithdraw(...)` call is replaced by an `addTransaction` + // for the cart. + const { milestoneStatus, isMilestoneLoading, canWithdraw, milestoneBlockError } = + useInitiateWithdraw(stakerAddress, { + registryAddress, + milestoneId, + atpType, + }); - const { - finalizeWithdraw, - isPending: isFinalizingWithdraw, - isConfirming: isConfirmingFinalize, - isSuccess: isFinalizeSuccess, - error: finalizeError, - } = useFinalizeWithdraw(); + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart(); - // Determine if milestone gates operations - const isMATP = atpType === 'MATP'; + const isMATP = atpType === "MATP"; const isMilestoneGated = isMATP && !canWithdraw; const canInitiateUnstake = - (status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE) - && !isMilestoneGated; // Block if milestone not succeeded - - const canFinalizeWithdrawNow = - canFinalize - && !isMilestoneGated; // Block if milestone not succeeded - - // Handle initiate withdraw errors - useEffect(() => { - if (initiateError) { - const errorMessage = initiateError.message; - if ( - errorMessage.includes("User rejected") || - errorMessage.includes("rejected") - ) { - showAlert("warning", "Transaction was cancelled"); - } - } - }, [initiateError, showAlert]); - - // Handle finalize withdraw errors - useEffect(() => { - if (finalizeError) { - const errorMessage = finalizeError.message; - if ( - errorMessage.includes("User rejected") || - errorMessage.includes("rejected") - ) { - showAlert("warning", "Transaction was cancelled"); - } - } - }, [finalizeError, showAlert]); - - // Call onSuccess callback when transaction succeeds - useEffect(() => { - if (isInitiateSuccess || isFinalizeSuccess) { - onSuccess?.(); - } - }, [isInitiateSuccess, isFinalizeSuccess, onSuccess]); - - const handleInitiateWithdraw = async () => { - try { - await initiateWithdraw(rollupVersion, attesterAddress); - } catch (error) { - console.error("Failed to initiate withdraw:", error); + (status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE) && + !isMilestoneGated; + const canFinalizeWithdrawNow = canFinalize && !isMilestoneGated; + + // Pre-build the cart entries used by the click handlers. We do NOT use + // their raw-calldata signature to detect "already queued" — that flickers + // when underlying data (rollup version, attester) refetches mid-render and + // causes duplicate cart entries. Use the stable stepGroupIdentifier from + // the entry's metadata instead (see `checkStepGroupInQueue`). + const initiateEntry = buildStakerInitiateWithdrawEntry({ + stakerAddress, + version: rollupVersion, + attester: attesterAddress, + providerName, + }); + const finalizeEntry = buildRollupFinalizeWithdrawEntry({ + rollupAddress, + attester: attesterAddress, + providerName, + }); + const isInitiateQueued = !!initiateEntry.metadata?.stepType + && !!initiateEntry.metadata?.stepGroupIdentifier + && checkStepGroupInQueue(initiateEntry.metadata.stepType, initiateEntry.metadata.stepGroupIdentifier); + const isFinalizeQueued = !!finalizeEntry.metadata?.stepType + && !!finalizeEntry.metadata?.stepGroupIdentifier + && checkStepGroupInQueue(finalizeEntry.metadata.stepType, finalizeEntry.metadata.stepGroupIdentifier); + + const handleInitiateClick = () => { + if (isInitiateQueued) { + openCart(); + return; } + addTransaction(initiateEntry, { preventDuplicate: true }); + onSuccess?.(); + openCart(); }; - const handleFinalizeWithdraw = async () => { - try { - await finalizeWithdraw(attesterAddress, rollupAddress); - } catch (error) { - console.error("Failed to finalize withdraw:", error); + const handleFinalizeClick = () => { + if (isFinalizeQueued) { + openCart(); + return; } + addTransaction(finalizeEntry, { preventDuplicate: true }); + onSuccess?.(); + openCart(); }; + const initiateLabel = isInitiateQueued ? "In Batch — Open Cart" : "Add Initiate Unstake"; + const finalizeLabel = isFinalizeQueued ? "In Batch — Open Cart" : "Add Finalize Withdraw"; + return (
@@ -201,56 +126,48 @@ export const WithdrawalActions = ({ Withdrawal Actions
- {/* Show milestone status for MATPs */} {isMATP && (
- +
)} - {/* Show milestone error message */} {milestoneBlockError && (
-
- {milestoneBlockError} -
+
{milestoneBlockError}
)} +
@@ -259,24 +176,26 @@ export const WithdrawalActions = ({
+
- - {initiateError && - !( - initiateError.message.includes("User rejected") || - initiateError.message.includes("rejected") - ) && ( -
-
- Transaction Error -
-
- {parseContractError(initiateError)} -
-
- )} - - {finalizeError && - !( - finalizeError.message.includes("User rejected") || - finalizeError.message.includes("rejected") - ) && ( -
-
- Transaction Error -
-
- {parseContractError(finalizeError)} -
-
- )} - - {(isInitiateSuccess || isFinalizeSuccess) && ( -
-
- {isInitiateSuccess ? "Unstake Initiated" : "Withdrawal Finalized"} -
-
- )}
); }; diff --git a/staking-dashboard/src/components/AdminTools/AdminTools.tsx b/staking-dashboard/src/components/AdminTools/AdminTools.tsx index 2998c98a1..db5607abc 100644 --- a/staking-dashboard/src/components/AdminTools/AdminTools.tsx +++ b/staking-dashboard/src/components/AdminTools/AdminTools.tsx @@ -2,28 +2,25 @@ import styles from "./AdminTools.module.css"; import { useState, useEffect } from "react"; import { useAccount, - useWriteContract, useReadContract, - useWaitForTransactionReceipt, } from "wagmi"; import { - useRegisterProvider, useProviderRegisteredEvents, - useProviderQueueLength, } from "../../hooks/stakingRegistry"; import { useAtpRegistryData } from "../../hooks"; import { contracts } from "../../contracts"; +import { useTransactionCart } from "@/contexts/TransactionCartContext"; +import { + buildRegisterProviderEntry, + buildAddKeysToProviderEntry, + type ProviderKeyStore, +} from "@/utils/actionCart"; import type { Address } from "viem"; export default function AdminTools() { const { address } = useAccount(); + const { addTransaction, openCart } = useTransactionCart(); - const registerProviderHook = useRegisterProvider(); - - const addKeysHook = useWriteContract(); - const addKeysReceipt = useWaitForTransactionReceipt({ - hash: addKeysHook.data, - }); const [selectedProviderId, setSelectedProviderId] = useState(1); const { executeAllowedAt } = useAtpRegistryData(); @@ -34,9 +31,8 @@ export default function AdminTools() { address: contracts.atpRegistry.address, functionName: "owner", }); - const { hasRegisteredProviders, providerCount, events, refetchEvents } = + const { hasRegisteredProviders, providerCount, events } = useProviderRegisteredEvents(); - const { refetchQueueLength } = useProviderQueueLength(selectedProviderId); // Console log the ATPRegistry owner when it changes useEffect(() => { @@ -75,46 +71,15 @@ export default function AdminTools() { console.error("No address is connected thus can't set providerAdmin"); return; } - try { - registerProviderHook.registerProvider(address); - } catch (error) { - console.log("Failed to register provider", error); - } + addTransaction( + buildRegisterProviderEntry({ providerAdmin: address }), + { preventDuplicate: true }, + ); + openCart(); }; - useEffect(() => { - if (registerProviderHook.isSuccess) { - refetchEvents(); - } - }, [registerProviderHook.isSuccess, refetchEvents]); - - useEffect(() => { - if (addKeysHook.data) { - console.log("Keys added to provider - Transaction hash:", addKeysHook.data); - } - if (addKeysReceipt.isSuccess) { - console.log("Keys added to provider - Transaction confirmed on-chain"); - // Refetch queue length when key is successfully confirmed - refetchQueueLength(); - } - if (addKeysHook.isError || addKeysReceipt.isError) { - console.log( - "Keys added to provider - Transaction failed:", - addKeysHook.error || addKeysReceipt.error, - ); - } - }, [ - addKeysHook.data, - addKeysReceipt.isSuccess, - addKeysHook.isError, - addKeysReceipt.isError, - addKeysHook.error, - addKeysReceipt.error, - refetchQueueLength, - ]); - // Generate fake keystore like in tests - const makeKeyStore = (attesterAddress: Address) => { + const makeKeyStore = (attesterAddress: Address): ProviderKeyStore => { return { attester: attesterAddress, publicKeyG1: { @@ -134,28 +99,20 @@ export default function AdminTools() { }; }; - const handleAddKeysToProvider = async () => { - try { - // Generate a fake attester address based on provider ID - const fakeAttester = - `0x${selectedProviderId.toString().padStart(40, "0")}` as `0x${string}`; - const keyStore = makeKeyStore(fakeAttester); + const handleAddKeysToProvider = () => { + // Generate a fake attester address based on provider ID + const fakeAttester = + `0x${selectedProviderId.toString().padStart(40, "0")}` as `0x${string}`; + const keyStore = makeKeyStore(fakeAttester); - console.log(`Adding keys to provider ${selectedProviderId}:`, { + addTransaction( + buildAddKeysToProviderEntry({ providerId: selectedProviderId, - attester: fakeAttester, - keyStore, - }); - - addKeysHook.writeContract({ - abi: contracts.stakingRegistry.abi, - address: contracts.stakingRegistry.address, - functionName: "addKeysToProvider", - args: [BigInt(selectedProviderId), [keyStore]], - }); - } catch (error) { - console.error("Failed to add keys to provider:", error); - } + keyStores: [keyStore], + }), + { preventDuplicate: true }, + ); + openCart(); }; return ( @@ -183,23 +140,15 @@ export default function AdminTools() { - {/* {addKeysHook.isSuccess && ( -
- Keys added to Provider {selectedProviderId} -
- )} */} - {(addKeysHook.isError || addKeysReceipt.isError) && ( -
- Failed to add keys:{" "} - {addKeysHook.error?.message || addKeysReceipt.error?.message} -
- )} ); diff --git a/staking-dashboard/src/components/Governance/PendingWithdrawals.tsx b/staking-dashboard/src/components/Governance/PendingWithdrawals.tsx index 8832c525b..1e967d3a7 100644 --- a/staking-dashboard/src/components/Governance/PendingWithdrawals.tsx +++ b/staking-dashboard/src/components/Governance/PendingWithdrawals.tsx @@ -1,9 +1,11 @@ -import { useState, useEffect, useRef, useMemo } from "react"; -import { useFinalizeWithdraw, type PendingWithdrawal } from "@/hooks/governance"; +import { useMemo } from "react"; +import { type PendingWithdrawal } from "@/hooks/governance"; import { formatTokenAmount } from "@/utils/atpFormatters"; -import { useAlert } from "@/contexts/AlertContext"; import { getExplorerAddressUrl } from "@/utils/explorerUtils"; import { contracts } from "@/contracts"; +import { useTransactionCart } from "@/contexts/TransactionCartContext"; +import { Icon } from "@/components/Icon"; +import { buildGovernanceFinalizeWithdrawEntry } from "@/utils/unstakeCart"; import type { Address } from "viem"; interface AtpInfo { @@ -32,7 +34,6 @@ export function PendingWithdrawals({ mayHaveOlderWithdrawals = false, onSuccess, }: PendingWithdrawalsProps) { - // Build a map of ATP address -> sequential number for source labeling const atpAddressToNumber = useMemo(() => { const map = new Map(); @@ -53,41 +54,21 @@ export function PendingWithdrawals({ } return "Unknown"; }; - const finalizeWithdraw = useFinalizeWithdraw(); - const { showAlert } = useAlert(); - const [finalizingId, setFinalizingId] = useState(null); - - // Track previous success state to detect transitions - const prevSuccessRef = useRef(false); - useEffect(() => { - if (finalizeWithdraw.isSuccess && !prevSuccessRef.current) { - setFinalizingId(null); - onSuccess(); - } - prevSuccessRef.current = finalizeWithdraw.isSuccess; - }, [finalizeWithdraw.isSuccess, onSuccess]); + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart(); - const handleFinalize = async (withdrawalId: bigint) => { - setFinalizingId(withdrawalId); - try { - await finalizeWithdraw.finalizeWithdraw(withdrawalId); - } catch (error) { - const message = error instanceof Error ? error.message : "Finalize failed"; - showAlert("error", message); - setFinalizingId(null); - } + const handleFinalize = (withdrawalId: bigint) => { + const entry = buildGovernanceFinalizeWithdrawEntry({ withdrawalId }); + addTransaction(entry, { preventDuplicate: true }); + onSuccess(); + openCart(); }; - // Watch for transaction errors from hook - const prevErrorRef = useRef(false); - useEffect(() => { - if (finalizeWithdraw.isError && !prevErrorRef.current && finalizeWithdraw.error) { - showAlert("error", finalizeWithdraw.error.message); - setFinalizingId(null); - } - prevErrorRef.current = finalizeWithdraw.isError; - }, [finalizeWithdraw.isError, finalizeWithdraw.error, showAlert]); + const isWithdrawalQueued = (withdrawalId: bigint): boolean => { + const entry = buildGovernanceFinalizeWithdrawEntry({ withdrawalId }); + if (!entry.metadata?.stepType || !entry.metadata?.stepGroupIdentifier) return false; + return checkStepGroupInQueue(entry.metadata.stepType, entry.metadata.stepGroupIdentifier); + }; const formatUnlockTime = (unlocksAt: bigint) => { const now = BigInt(Math.floor(Date.now() / 1000)); @@ -105,11 +86,7 @@ export function PendingWithdrawals({ }; if (isLoading) { - return ( -
- Loading withdrawals... -
- ); + return
Loading withdrawals...
; } if (pendingWithdrawals.length === 0 && !mayHaveOlderWithdrawals) { @@ -129,9 +106,9 @@ export function PendingWithdrawals({ sourceLabel={getSourceLabel(withdrawal.recipient)} symbol={symbol} decimals={decimals} - isProcessing={finalizingId === withdrawal.withdrawalId} - isPending={finalizeWithdraw.isPending || finalizeWithdraw.isConfirming} - onFinalize={() => handleFinalize(withdrawal.withdrawalId)} + isQueued={isWithdrawalQueued(withdrawal.withdrawalId)} + onAddToBatch={() => handleFinalize(withdrawal.withdrawalId)} + onOpenCart={openCart} formatUnlockTime={formatUnlockTime} /> ))} @@ -159,9 +136,9 @@ interface WithdrawalRowProps { sourceLabel: string; symbol?: string; decimals: number; - isProcessing: boolean; - isPending: boolean; - onFinalize: () => void; + isQueued: boolean; + onAddToBatch: () => void; + onOpenCart: () => void; formatUnlockTime: (unlocksAt: bigint) => string; } @@ -170,9 +147,9 @@ function WithdrawalRow({ sourceLabel, symbol, decimals, - isProcessing, - isPending, - onFinalize, + isQueued, + onAddToBatch, + onOpenCart, formatUnlockTime, }: WithdrawalRowProps) { const unlockText = formatUnlockTime(withdrawal.unlocksAt); @@ -185,13 +162,22 @@ function WithdrawalRow({ {formatTokenAmount(withdrawal.amount, decimals, symbol)} {isReady ? ( - + isQueued ? ( + + ) : ( + + ) ) : ( unlocks in {unlockText} diff --git a/staking-dashboard/src/components/Governance/WithdrawFromGovernanceModal.tsx b/staking-dashboard/src/components/Governance/WithdrawFromGovernanceModal.tsx index da1fbeda3..d12b7b1d3 100644 --- a/staking-dashboard/src/components/Governance/WithdrawFromGovernanceModal.tsx +++ b/staking-dashboard/src/components/Governance/WithdrawFromGovernanceModal.tsx @@ -3,13 +3,14 @@ import { createPortal } from "react-dom"; import { formatUnits, parseUnits } from "viem"; import { useAccount } from "wagmi"; import { Icon } from "@/components/Icon"; -import { - useGovernanceWithdraw, - useInitiateWithdrawFromGovernance, - type StakerVotingPower, -} from "@/hooks/governance"; +import { type StakerVotingPower } from "@/hooks/governance"; import { formatTokenAmount } from "@/utils/atpFormatters"; import { useAlert } from "@/contexts/AlertContext"; +import { useTransactionCart } from "@/contexts/TransactionCartContext"; +import { + buildGovernanceInitiateWithdrawEntry, + buildGovernanceWalletInitiateWithdrawEntry, +} from "@/utils/unstakeCart"; // Withdraw source can be "wallet" (direct deposit) or an ATP (staker) type WithdrawSource = @@ -26,6 +27,18 @@ interface WithdrawFromGovernanceModalProps { onSuccess: () => void; } +/** + * Modal to queue a governance withdrawal initiation in the cart. Two source + * variants converge here: + * + * - "wallet" — `Governance.initiateWithdraw(to, amount)` (direct ERC20 deposits) + * - "atp" — `Staker.initiateWithdrawFromGovernance(amount)` (ATP holders) + * + * Each is `msg.sender`-bound on contract side, so EOA wallets sign sequentially + * but Safe wallets batch the whole cart into a single proposal. Cart-routing + * keeps the user's pending action visible across page reloads via the cart's + * localStorage persistence. + */ export function WithdrawFromGovernanceModal({ isOpen, onClose, @@ -40,29 +53,24 @@ export function WithdrawFromGovernanceModal({ const [selectedSourceIndex, setSelectedSourceIndex] = useState(0); const inputRef = useRef(null); const { showAlert } = useAlert(); + const { addTransaction, openCart } = useTransactionCart(); // Build available sources - wallet first (if has deposits), then ATPs with deposits const availableSources = useMemo(() => { const sources: WithdrawSource[] = []; - - // Add wallet source if user has direct deposits if (directDepositBalance > 0n) { sources.push({ type: "wallet", depositedAmount: directDepositBalance }); } - - // Add ATP sources with governance deposits for (const stakerPower of stakerPowers) { if (stakerPower.power > 0n) { sources.push({ type: "atp", stakerPower }); } } - return sources; }, [directDepositBalance, stakerPowers]); const selectedSource = availableSources[selectedSourceIndex] ?? availableSources[0]; - // Get deposited amount for selected source const depositedBalance = useMemo(() => { if (!selectedSource) return 0n; if (selectedSource.type === "wallet") { @@ -71,108 +79,58 @@ export function WithdrawFromGovernanceModal({ return selectedSource.stakerPower.power; }, [selectedSource]); - // Hooks for withdrawal - const governanceWithdraw = useGovernanceWithdraw(); - const selectedStakerAddress = - selectedSource?.type === "atp" ? selectedSource.stakerPower.stakerAddress : undefined; - const atpWithdraw = useInitiateWithdrawFromGovernance(selectedStakerAddress); - const parsedAmount = parseUnits(amount || "0", decimals); const canWithdraw = parsedAmount > 0n && parsedAmount <= depositedBalance; - // Track previous success states to detect transitions - const prevSuccessRef = useRef({ - walletWithdraw: false, - atpWithdraw: false, - }); - - useEffect(() => { - const prev = prevSuccessRef.current; - - // Wallet withdrawal initiated successfully - if (governanceWithdraw.isSuccess && !prev.walletWithdraw) { - setAmount(""); - onSuccess(); - onClose(); - } - - // ATP withdrawal initiated successfully - if (atpWithdraw.isSuccess && !prev.atpWithdraw) { - setAmount(""); - onSuccess(); - onClose(); - } - - prevSuccessRef.current = { - walletWithdraw: governanceWithdraw.isSuccess, - atpWithdraw: atpWithdraw.isSuccess, - }; - }, [governanceWithdraw.isSuccess, atpWithdraw.isSuccess, onSuccess, onClose]); - // Reset state and focus input when modal opens useEffect(() => { if (isOpen) { setAmount(""); setSelectedSourceIndex(0); - prevSuccessRef.current = { - walletWithdraw: false, - atpWithdraw: false, - }; setTimeout(() => inputRef.current?.focus(), 0); } }, [isOpen]); - const handleWithdraw = async () => { - if (!selectedSource) return; + const handleAddToBatch = () => { + if (!selectedSource || !canWithdraw) return; try { if (selectedSource.type === "wallet") { - if (!userAddress) return; - await governanceWithdraw.initiateWithdraw(userAddress, parsedAmount); + if (!userAddress) { + showAlert("error", "Wallet not connected"); + return; + } + addTransaction( + buildGovernanceWalletInitiateWithdrawEntry({ + to: userAddress, + amount: parsedAmount, + }), + { preventDuplicate: true }, + ); } else { - await atpWithdraw.initiateWithdraw(parsedAmount); + addTransaction( + buildGovernanceInitiateWithdrawEntry({ + stakerAddress: selectedSource.stakerPower.stakerAddress, + amount: parsedAmount, + }), + { preventDuplicate: true }, + ); } + setAmount(""); + onSuccess(); + openCart(); + onClose(); } catch (error) { - const message = error instanceof Error ? error.message : "Withdrawal failed"; + const message = error instanceof Error ? error.message : "Failed to queue withdrawal"; showAlert("error", message); } }; - // Watch for transaction errors from hooks - const prevErrorRef = useRef({ - walletWithdraw: false, - atpWithdraw: false, - }); - - useEffect(() => { - const prev = prevErrorRef.current; - if (governanceWithdraw.isError && !prev.walletWithdraw && governanceWithdraw.error) { - showAlert("error", governanceWithdraw.error.message); - } - if (atpWithdraw.isError && !prev.atpWithdraw && atpWithdraw.error) { - showAlert("error", atpWithdraw.error.message); - } - prevErrorRef.current = { - walletWithdraw: governanceWithdraw.isError, - atpWithdraw: atpWithdraw.isError, - }; - }, [ - governanceWithdraw.isError, - governanceWithdraw.error, - atpWithdraw.isError, - atpWithdraw.error, - showAlert, - ]); - - const isPending = governanceWithdraw.isPending || atpWithdraw.isPending; - const isConfirming = governanceWithdraw.isConfirming || atpWithdraw.isConfirming; - if (!isOpen) return null; return createPortal(
- {/* Header */}

Withdraw from Governance

- {/* Info */}

- Initiate a withdrawal of your deposited tokens. After the lock period, you can finalize - the withdrawal to receive your tokens. + Queue an initiate-withdraw transaction in the batch cart. After the lock period passes you + can finalize the withdrawal via the Manage Withdrawals UI to receive your tokens.

- {/* Source selection dropdown */} {availableSources.length > 1 && (
@@ -226,7 +182,6 @@ export function WithdrawFromGovernanceModal({
)} - {/* Amount input */}
@@ -247,16 +202,14 @@ export function WithdrawFromGovernanceModal({
- {/* Action button */} - {/* Cancel */}
)}
diff --git a/staking-dashboard/src/components/TransactionCart/MulticallBatchHeader.tsx b/staking-dashboard/src/components/TransactionCart/MulticallBatchHeader.tsx new file mode 100644 index 000000000..41d22ad71 --- /dev/null +++ b/staking-dashboard/src/components/TransactionCart/MulticallBatchHeader.tsx @@ -0,0 +1,62 @@ +import { Icon } from "@/components/Icon" +import type { CartTransaction } from "@/contexts/TransactionCartContext" + +interface MulticallBatchHeaderProps { + /** Entries that will be bundled into a single Multicall3 transaction. */ + transactions: CartTransaction[] + isExpanded: boolean + onToggle: () => void + /** Whether at least one of the wrapped entries is currently executing. */ + isExecuting: boolean +} + +/** + * Header row that wraps the cart entries that will run through Multicall3. + * Communicates "this whole list will be one wallet signature, not N" — and + * doubles as a dropdown affordance to fold the individual entries away. + * + * Renders only when the cart's pending entries are Multicall3-eligible (see + * `isMulticall3Eligible` in `useMulticall3Execution.ts`). The wrapped entries + * render below this header in the parent component when `isExpanded` is true. + */ +export const MulticallBatchHeader = ({ + transactions, + isExpanded, + onToggle, + isExecuting, +}: MulticallBatchHeaderProps) => { + const count = transactions.length + + return ( + + ) +} diff --git a/staking-dashboard/src/components/TransactionCart/TransactionCart.tsx b/staking-dashboard/src/components/TransactionCart/TransactionCart.tsx index 701745ffa..fe0b5e26c 100644 --- a/staking-dashboard/src/components/TransactionCart/TransactionCart.tsx +++ b/staking-dashboard/src/components/TransactionCart/TransactionCart.tsx @@ -16,7 +16,7 @@ export const TransactionCart = () => { } return ( -
+
)} + {transaction.type === "unstake" && ( + <> + {transaction.metadata.stepType && ( +
+
Step
+ + {UnstakeStepTypeName[transaction.metadata.stepType]} + +
+ )} + {transaction.metadata.attesterAddress && ( +
+
Attester
+
+ + {transaction.metadata.attesterAddress} + + +
+
+ )} + {transaction.metadata.recipient && ( +
+
Recipient
+
+ + {transaction.metadata.recipient} + + +
+
+ )} + {transaction.metadata.rollupAddress && ( +
+
Rollup
+
+ + {transaction.metadata.rollupAddress} + + +
+
+ )} + {transaction.metadata.stakerAddress && ( +
+
Staker Contract
+
+ + {transaction.metadata.stakerAddress} + + +
+
+ )} + {transaction.metadata.amount !== undefined && transaction.metadata.amount > 0n && ( +
+
Amount (raw)
+ + {transaction.metadata.amount.toString()} + +
+ )} + {transaction.metadata.withdrawalId !== undefined && ( +
+
Withdrawal ID
+ + {transaction.metadata.withdrawalId.toString()} + +
+ )} + {transaction.metadata.providerName && ( +
+
Provider
+ + {transaction.metadata.providerName} + +
+ )} + + )} + {transaction.type === "action" && ( + <> + {transaction.metadata.stepType && ( +
+
Step
+ + {ActionStepTypeName[transaction.metadata.stepType]} + +
+ )} + {transaction.metadata.contractAddress && ( +
+
Contract
+
+ + {transaction.metadata.contractAddress} + + +
+
+ )} + {transaction.metadata.atpAddress && ( +
+
Token Vault Address
+
+ + {transaction.metadata.atpAddress} + + +
+
+ )} + {transaction.metadata.providerId !== undefined && ( +
+
Provider ID
+ + {transaction.metadata.providerId} + +
+ )} + + )} {transaction.type === "self-stake" && "atpAddress" in transaction.metadata && ( <> {transaction.metadata.atpAddress && ( diff --git a/staking-dashboard/src/components/TransactionCart/TransactionCartExpanded.tsx b/staking-dashboard/src/components/TransactionCart/TransactionCartExpanded.tsx index 84c6710b9..b9590339a 100644 --- a/staking-dashboard/src/components/TransactionCart/TransactionCartExpanded.tsx +++ b/staking-dashboard/src/components/TransactionCart/TransactionCartExpanded.tsx @@ -1,8 +1,11 @@ -import { useState } from "react" +import { useState, useMemo, Fragment } from "react" import { Icon } from "@/components/Icon" import { useTransactionCart } from "@/contexts/TransactionCartContext" import { useTermsModal } from "@/contexts/TermsModalContext" import { TransactionCartDetailsExpanded } from "./TransactionCartDetailsExpanded" +import { MulticallBatchHeader } from "./MulticallBatchHeader" +import { planExecution } from "@/hooks/transactionCart/useMulticall3Execution" +import type { CartTransaction } from "@/contexts/TransactionCartContext" interface TransactionCartExpandedProps { onMinimize: () => void @@ -21,23 +24,53 @@ export const TransactionCartExpanded = ({ onMinimize }: TransactionCartExpandedP isExecuting, currentExecutingId, moveUp, - moveDown + moveDown, + isSafe, } = useTransactionCart() const { requireTermsAcceptance } = useTermsModal() const [expandedTxId, setExpandedTxId] = useState(null) + /** + * Tracks which multicall segments the user has collapsed. We key by the + * first entry's id (stable across re-renders of the same segment), and + * default to "all expanded" — adding to the set means collapsed. + */ + const [collapsedSegmentIds, setCollapsedSegmentIds] = useState>(new Set()) const toggleExpand = (txId: string) => { setExpandedTxId(expandedTxId === txId ? null : txId) } + const toggleSegment = (segmentId: string) => { + setCollapsedSegmentIds((prev) => { + const next = new Set(prev) + if (next.has(segmentId)) next.delete(segmentId) + else next.add(segmentId) + return next + }) + } + const handleExecuteClick = () => { requireTermsAcceptance(executeAll) } const pendingCount = transactions.filter(tx => tx.status === 'pending' || tx.status === undefined).length + // Plan the pending cart's execution: contiguous batchable entries collapse + // into multicall segments; everything else falls into sequential segments. + // Safe wallets bypass segmentation entirely — their SDK batches natively. + const { plan, nonPending } = useMemo(() => { + if (isSafe) return { plan: [], nonPending: transactions } + const pending: CartTransaction[] = [] + const nonPending: CartTransaction[] = [] + for (const tx of transactions) { + if (tx.status === 'pending' || tx.status === undefined) pending.push(tx) + else nonPending.push(tx) + } + return { plan: planExecution(pending), nonPending } + }, [transactions, isSafe]) + return (
{/* Cart Header */} @@ -131,8 +164,14 @@ export const TransactionCartExpanded = ({ onMinimize }: TransactionCartExpandedP

No transactions in queue

Add delegations or claims to batch execute them

- ) : - transactions.map((tx, index) => { + ) : (() => { + // `index` here is the row's overall position in `transactions`, so + // moveUp/moveDown still work against the underlying cart order. + // `indented` adds a subtle visual indent for rows wrapped under the + // Multicall3 batch header — readers can see at a glance which rows + // belong to the batch. + const renderRow = (tx: CartTransaction, indented: boolean) => { + const index = transactions.indexOf(tx) const isCurrentlyExecuting = currentExecutingId === tx.id const isPending = isExecuting && !isCurrentlyExecuting const canMoveUp = index > 0 && !isExecuting @@ -142,7 +181,7 @@ export const TransactionCartExpanded = ({ onMinimize }: TransactionCartExpandedP return (
}
) - })} + } + + return ( + <> + {/* Non-batched rows render first (completed/failed history when a + prior multicall executed, or all rows when no batch applies). */} + {/* Non-pending entries first (history of completed/failed). */} + {nonPending.map((tx) => renderRow(tx, false))} + + {/* Plan segments — multicall segments get a collapsible header, + sequential segments render flat. Empty plan (Safe wallet) + means nothing extra below the non-pending block. */} + {plan.map((segment, segIdx) => { + if (segment.kind === 'sequential') { + return ( + + {segment.entries.map((tx) => renderRow(tx, false))} + + ) + } + + // Multicall segment — header wraps the entries. Identity for + // the collapse state comes from the first entry's id (stable + // across renders of the same segment). + const segmentId = segment.entries[0].id + const isSegmentExpanded = !collapsedSegmentIds.has(segmentId) + const isSegmentExecuting = segment.entries.some((e) => e.status === 'executing') + return ( + + toggleSegment(segmentId)} + isExecuting={isSegmentExecuting} + /> + {isSegmentExpanded && segment.entries.map((tx) => renderRow(tx, true))} + + ) + })} + + ) + })()}
) diff --git a/staking-dashboard/src/components/UpgradeStakerModal/UpgradeStakerModal.tsx b/staking-dashboard/src/components/UpgradeStakerModal/UpgradeStakerModal.tsx index cce22995f..7ea2a12f3 100644 --- a/staking-dashboard/src/components/UpgradeStakerModal/UpgradeStakerModal.tsx +++ b/staking-dashboard/src/components/UpgradeStakerModal/UpgradeStakerModal.tsx @@ -1,10 +1,12 @@ -import { useState, useMemo, useEffect } from "react" +import { useMemo } from "react" import type { Address } from "viem" import { formatEther } from "viem" import { useAtpRegistryData, useStakerImplementations } from "@/hooks/atpRegistry" import { useStakerImplementation } from "@/hooks/staker/useStakerImplementation" -import { useUpgradeStaker } from "@/hooks/atp" import { TooltipIcon } from "@/components/Tooltip" +import { Icon } from "@/components/Icon" +import { useTransactionCart } from "@/contexts/TransactionCartContext" +import { buildUpgradeStakerEntry } from "@/utils/actionCart" import { getVersionByImplementation, getImplementationDescription, @@ -17,6 +19,7 @@ interface UpgradeStakerModalProps { isOpen: boolean onClose: () => void atp: ATPData + // Kept for source-compatibility; cart execution drives refetch globally. onSuccess?: () => void } @@ -24,15 +27,13 @@ export const UpgradeStakerModal = ({ isOpen, onClose, atp, - onSuccess }: UpgradeStakerModalProps) => { - const [hasUpgraded, setHasUpgraded] = useState(false) + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart() // Get current implementation from staker contract const { implementation: currentImplementation, isLoading: isLoadingImplementation, - refetch: refetchImplementation } = useStakerImplementation(atp.staker as Address) // Get available versions from registry @@ -41,9 +42,6 @@ export const UpgradeStakerModal = ({ }) const { implementations, isLoading: isLoadingImplementations } = useStakerImplementations(stakerVersions, atp.registry) - // Upgrade hook - const upgradeStakerHook = useUpgradeStaker(atp.atpAddress as Address) - // Get current version number const currentVersion = useMemo(() => { return getVersionByImplementation(currentImplementation, implementations) @@ -61,29 +59,19 @@ export const UpgradeStakerModal = ({ : undefined const latestDescription = getImplementationDescription(latestImplementation, latestVersion ?? undefined) - // Handle successful upgrade - useEffect(() => { - if (upgradeStakerHook.isSuccess && !hasUpgraded) { - setHasUpgraded(true) - refetchImplementation() - onSuccess?.() - } - }, [upgradeStakerHook.isSuccess, hasUpgraded, refetchImplementation, onSuccess]) - - // Reset state when modal closes - useEffect(() => { - if (!isOpen) { - setHasUpgraded(false) - } - }, [isOpen]) - - const handleUpgrade = async () => { - if (!latestVersion) return - try { - await upgradeStakerHook.upgradeStaker(latestVersion) - } catch (error) { - console.error('Failed to upgrade staker:', error) - } + const upgradeEntry = useMemo(() => { + if (!latestVersion) return undefined + return buildUpgradeStakerEntry({ atpAddress: atp.atpAddress as Address, version: latestVersion }) + }, [latestVersion, atp.atpAddress]) + + const isUpgradeQueued = !!upgradeEntry && !!upgradeEntry.metadata?.stepType && + !!upgradeEntry.metadata?.stepGroupIdentifier && + checkStepGroupInQueue(upgradeEntry.metadata.stepType, upgradeEntry.metadata.stepGroupIdentifier) + + const handleUpgrade = () => { + if (!upgradeEntry) return + addTransaction(upgradeEntry, { preventDuplicate: true }) + openCart() } const handleClose = () => { @@ -93,7 +81,6 @@ export const UpgradeStakerModal = ({ if (!isOpen) return null const isLoading = isLoadingImplementation || isLoadingImplementations - const isProcessing = upgradeStakerHook.isPending || upgradeStakerHook.isConfirming const needsUpgrade = currentVersion === null || currentVersion === 0n || (latestVersion !== null && currentVersion < latestVersion) const isAlreadyLatest = latestVersion !== null && currentVersion === latestVersion @@ -194,42 +181,30 @@ export const UpgradeStakerModal = ({
{/* Upgrade Button / Status */} - {hasUpgraded || upgradeStakerHook.isSuccess ? ( -
- - ✓ Successfully upgraded to v{latestVersion?.toString()} - -
- ) : isAlreadyLatest ? ( + {isAlreadyLatest ? (
Already on latest version
) : needsUpgrade && latestVersion !== null ? ( - + isUpgradeQueued ? ( + + ) : ( + + ) ) : null} - - {/* Error Message */} - {upgradeStakerHook.error && ( -
- {upgradeStakerHook.error.message.includes('rejected') - ? 'Transaction cancelled by user' - : 'Upgrade failed. Please try again.' - } -
- )}
)} diff --git a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx index 60cfcc1bd..8bbb583ba 100644 --- a/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx +++ b/staking-dashboard/src/components/WalletStakesDetailsModal/WalletWithdrawalActions.tsx @@ -1,65 +1,13 @@ -import { useEffect, useRef } from "react" import type { Address } from "viem" -import { useWalletInitiateWithdraw, useFinalizeWithdraw, SequencerStatus } from "@/hooks/rollup" +import { SequencerStatus } from "@/hooks/rollup" import { TooltipIcon } from "@/components/Tooltip" -import { useAlert } from "@/contexts/AlertContext" +import { Icon } from "@/components/Icon" +import { useTransactionCart } from "@/contexts/TransactionCartContext" import { getUnlockTimeDisplay } from "@/utils/dateFormatters" - -/** - * Parse contract errors to extract user-friendly messages - */ -function parseContractError(error: Error): string { - const message = error.message || "" - - const errorMappings: Record = { - "Staking__NotExiting": "Sequencer is not in exiting state. Initiate unstake first.", - "Staking__ExitDelayNotPassed": "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", - "Staking__WithdrawalDelayNotPassed": "Withdrawal delay has not passed yet. Please wait for the withdrawal period to complete.", - "Staking__NotTheWithdrawer": "You are not the withdrawer for this stake. Only the original staker can initiate withdrawal.", - "NotExiting": "Sequencer is not in exiting state.", - "ExitDelayNotPassed": "Exit delay has not passed yet.", - "NotTheWithdrawer": "Only the withdrawer can initiate withdrawal.", - "0xef566ee0": "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", - } - - for (const [pattern, friendlyMessage] of Object.entries(errorMappings)) { - if (message.includes(pattern)) { - return friendlyMessage - } - } - - const revertMatch = message.match(/reverted with.*?["']([^"']+)["']/i) - if (revertMatch) { - return revertMatch[1] - } - - const customErrorMatch = message.match(/error=\{[^}]*"data":"(0x[a-f0-9]+)"/i) - if (customErrorMatch) { - const errorData = customErrorMatch[1] - for (const [selector, friendlyMessage] of Object.entries(errorMappings)) { - if (errorData.startsWith(selector)) { - return friendlyMessage - } - } - } - - if (message.includes("nonce") && message.includes("0x")) { - const selectorMatch = message.match(/0x[a-f0-9]{8}/i) - if (selectorMatch) { - const selector = selectorMatch[0].toLowerCase() - if (selector === "0xef566ee0") { - return "Exit delay has not passed yet. Please wait for the withdrawal period to complete." - } - } - return "Transaction failed. The contract rejected the call - please check that all conditions are met." - } - - if (message.length > 200) { - return message.substring(0, 200) + "..." - } - - return message || "Transaction failed" -} +import { + buildRollupInitiateWithdrawEntry, + buildRollupFinalizeWithdrawEntry, +} from "@/utils/unstakeCart" interface WalletWithdrawalActionsProps { attesterAddress: Address @@ -73,8 +21,10 @@ interface WalletWithdrawalActionsProps { } /** - * Component for wallet stake withdrawal actions - * Calls the Rollup contract directly for initiate and finalize withdraw + * Initiate / finalize unstake actions for the wallet/ERC20 direct-staker path. + * Queues each as an `unstake` cart entry instead of firing immediately. Safe + * wallets batch the whole cart into one proposal; EOA wallets sign each entry + * sequentially (unstake is `msg.sender`-bound — Multicall3 can't batch these). */ export const WalletWithdrawalActions = ({ attesterAddress, @@ -86,83 +36,50 @@ export const WalletWithdrawalActions = ({ withdrawalDelayDays, onSuccess, }: WalletWithdrawalActionsProps) => { - const { showAlert } = useAlert() const isExiting = status === SequencerStatus.EXITING - const { - initiateWithdraw, - isPending: isInitiatingWithdraw, - isConfirming: isConfirmingInitiate, - isSuccess: isInitiateSuccess, - error: initiateError, - } = useWalletInitiateWithdraw() - - const { - finalizeWithdraw, - isPending: isFinalizingWithdraw, - isConfirming: isConfirmingFinalize, - isSuccess: isFinalizeSuccess, - error: finalizeError, - } = useFinalizeWithdraw() + const { addTransaction, checkStepGroupInQueue, openCart } = useTransactionCart() const canInitiateUnstake = status === SequencerStatus.VALIDATING || status === SequencerStatus.ZOMBIE - // Track if success callback was already fired to prevent duplicate calls - const successCallbackFiredRef = useRef(false) - - // Reset the ref when success states reset (new transaction cycle) - useEffect(() => { - if (!isInitiateSuccess && !isFinalizeSuccess) { - successCallbackFiredRef.current = false - } - }, [isInitiateSuccess, isFinalizeSuccess]) - - useEffect(() => { - if (initiateError) { - const errorMessage = initiateError.message - if ( - errorMessage.includes("User rejected") || - errorMessage.includes("rejected") - ) { - showAlert("warning", "Transaction was cancelled") - } - } - }, [initiateError, showAlert]) - - useEffect(() => { - if (finalizeError) { - const errorMessage = finalizeError.message - if ( - errorMessage.includes("User rejected") || - errorMessage.includes("rejected") - ) { - showAlert("warning", "Transaction was cancelled") - } - } - }, [finalizeError, showAlert]) - - useEffect(() => { - if ((isInitiateSuccess || isFinalizeSuccess) && !successCallbackFiredRef.current) { - successCallbackFiredRef.current = true - onSuccess?.() - } - }, [isInitiateSuccess, isFinalizeSuccess, onSuccess]) - - const handleInitiateWithdraw = async () => { - try { - await initiateWithdraw(attesterAddress, recipientAddress, rollupAddress) - } catch (error) { - console.error("Failed to initiate withdraw:", error) + const initiateEntry = buildRollupInitiateWithdrawEntry({ + rollupAddress, + attester: attesterAddress, + recipient: recipientAddress, + }) + const finalizeEntry = buildRollupFinalizeWithdrawEntry({ + rollupAddress, + attester: attesterAddress, + }) + // Queued-state check by stepGroupIdentifier (stable across data refetches) + // rather than raw calldata signature (would flicker if the underlying + // attester / rollup data changes mid-render and let the user double-queue). + const isInitiateQueued = !!initiateEntry.metadata?.stepType + && !!initiateEntry.metadata?.stepGroupIdentifier + && checkStepGroupInQueue(initiateEntry.metadata.stepType, initiateEntry.metadata.stepGroupIdentifier) + const isFinalizeQueued = !!finalizeEntry.metadata?.stepType + && !!finalizeEntry.metadata?.stepGroupIdentifier + && checkStepGroupInQueue(finalizeEntry.metadata.stepType, finalizeEntry.metadata.stepGroupIdentifier) + + const handleInitiateClick = () => { + if (isInitiateQueued) { + openCart() + return } + addTransaction(initiateEntry, { preventDuplicate: true }) + onSuccess?.() + openCart() } - const handleFinalizeWithdraw = async () => { - try { - await finalizeWithdraw(attesterAddress, rollupAddress) - } catch (error) { - console.error("Failed to finalize withdraw:", error) + const handleFinalizeClick = () => { + if (isFinalizeQueued) { + openCart() + return } + addTransaction(finalizeEntry, { preventDuplicate: true }) + onSuccess?.() + openCart() } return ( @@ -172,7 +89,7 @@ export const WalletWithdrawalActions = ({ Withdrawal Actions @@ -180,23 +97,26 @@ export const WalletWithdrawalActions = ({
@@ -207,17 +127,22 @@ export const WalletWithdrawalActions = ({
- - {initiateError && - !( - initiateError.message.includes("User rejected") || - initiateError.message.includes("rejected") - ) && ( -
-
- Transaction Error -
-
- {parseContractError(initiateError)} -
-
- )} - - {finalizeError && - !( - finalizeError.message.includes("User rejected") || - finalizeError.message.includes("rejected") - ) && ( -
-
- Transaction Error -
-
- {parseContractError(finalizeError)} -
-
- )} - - {(isInitiateSuccess || isFinalizeSuccess) && ( -
-
- {isInitiateSuccess ? "Unstake Initiated" : "Withdrawal Finalized"} -
-
- )}
) } diff --git a/staking-dashboard/src/contexts/TransactionCartContext.tsx b/staking-dashboard/src/contexts/TransactionCartContext.tsx index 5483dd087..af5a472b6 100644 --- a/staking-dashboard/src/contexts/TransactionCartContext.tsx +++ b/staking-dashboard/src/contexts/TransactionCartContext.tsx @@ -13,13 +13,15 @@ import type { SelfStakeMetadata, WalletDirectStakeMetadata, ClaimMetadata, + UnstakeMetadata, + ActionMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions, TransactionCartContextType } from "./TransactionCartContextType" -import { ClaimStepType, ClaimStepTypeName } from "./TransactionCartContextType" +import { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName, ActionStepType, ActionStepTypeName } from "./TransactionCartContextType" // Re-export types for backwards compatibility export type { @@ -28,13 +30,15 @@ export type { SelfStakeMetadata, WalletDirectStakeMetadata, ClaimMetadata, + UnstakeMetadata, + ActionMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions } -export { ClaimStepType, ClaimStepTypeName } +export { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName, ActionStepType, ActionStepTypeName } const TransactionCartContext = createContext(undefined) @@ -85,6 +89,27 @@ export function TransactionCartProvider({ children }: TransactionCartProviderPro return transactions.some(tx => getTransactionSignature(tx.transaction) === signature) }, [transactions]) + /** + * Identity-based variant for "is an entry for this logical operation already + * queued?". Matches by `metadata.stepType` + `metadata.stepGroupIdentifier` + * (the cart's stable per-operation identity), rather than by raw calldata + * hash. Use this when underlying chain data (e.g. a rollup version, an + * attester address being refetched) could change between renders and would + * make `checkTransactionInQueue` flicker false — leading users to add + * duplicate entries. + */ + const checkStepGroupInQueue = useCallback(( + stepType: string | number, + stepGroupIdentifier: string, + ): boolean => { + return transactions.some((tx) => { + const meta = tx.metadata + return !!meta && 'stepType' in meta && 'stepGroupIdentifier' in meta && + meta.stepType === stepType && + meta.stepGroupIdentifier === stepGroupIdentifier + }) + }, [transactions]) + /** * Resolve dependencies - find transactions that match the dependency metadata, to make sure the tranasction order is correct */ @@ -324,6 +349,7 @@ export function TransactionCartProvider({ children }: TransactionCartProviderPro moveUp, moveDown, checkTransactionInQueue, + checkStepGroupInQueue, getTransaction, getTransactionByTx, isSafe: isSafeApp, diff --git a/staking-dashboard/src/contexts/TransactionCartContextType.ts b/staking-dashboard/src/contexts/TransactionCartContextType.ts index 23210f79b..18ced970c 100644 --- a/staking-dashboard/src/contexts/TransactionCartContextType.ts +++ b/staking-dashboard/src/contexts/TransactionCartContextType.ts @@ -1,7 +1,7 @@ import type { Address } from "viem" import { ATPStakingStepsWithTransaction } from "./ATPStakingStepsContext" -export type TransactionType = "delegation" | "self-stake" | "setup" | "wallet-delegation" | "wallet-direct-stake" | "claim" +export type TransactionType = "delegation" | "self-stake" | "setup" | "wallet-delegation" | "wallet-direct-stake" | "claim" | "unstake" | "action" /** * Step type for claim flows. String values so they can't collide with @@ -28,6 +28,43 @@ export const ClaimStepTypeName: Record = { [ClaimStepType.SplitWithdraw]: "Withdraw Rewards", } +/** + * Step type for unstake/withdraw flows. Three paths (wallet ERC20, ATP staker, + * governance) × two phases (initiate, finalize) = six leaves. String values + * keep them from colliding with other step-type enums. + * + * Unstake operations are `msg.sender`-bound (the contract checks the caller + * matches the stored withdrawer), so they cannot be batched via Multicall3. + * For Safe wallets the Safe contract IS the stored withdrawer so a Safe + * proposal containing many initiate/finalize calls executes natively. + */ +export enum UnstakeStepType { + /** Rollup.initiateWithdraw(attester, recipient) — wallet ERC20 direct-staker path. */ + InitiateWithdrawRollup = "unstake:initiate-rollup", + /** Staker.initiateWithdraw(version, attester) — ATP staker path. */ + InitiateWithdrawStaker = "unstake:initiate-staker", + /** Staker.initiateWithdrawFromGovernance(amount) — governance ATP path. */ + InitiateWithdrawGovernance = "unstake:initiate-governance", + /** Governance.initiateWithdraw(to, amount) — direct-deposit ERC20 holders. */ + InitiateWithdrawGovernanceWallet = "unstake:initiate-governance-wallet", + /** Rollup.finalizeWithdraw(attester) — wallet ERC20 direct-staker path. */ + FinalizeWithdrawRollup = "unstake:finalize-rollup", + /** Staker.finalizeWithdraw(version, attester) — ATP staker path. */ + FinalizeWithdrawStaker = "unstake:finalize-staker", + /** Governance.finalizeWithdraw(withdrawalId) — governance path (different contract). */ + FinalizeWithdrawGovernance = "unstake:finalize-governance", +} + +export const UnstakeStepTypeName: Record = { + [UnstakeStepType.InitiateWithdrawRollup]: "Initiate Unstake", + [UnstakeStepType.InitiateWithdrawStaker]: "Initiate Unstake", + [UnstakeStepType.InitiateWithdrawGovernance]: "Initiate Governance Withdraw", + [UnstakeStepType.InitiateWithdrawGovernanceWallet]: "Initiate Governance Withdraw", + [UnstakeStepType.FinalizeWithdrawRollup]: "Finalize Unstake", + [UnstakeStepType.FinalizeWithdrawStaker]: "Finalize Unstake", + [UnstakeStepType.FinalizeWithdrawGovernance]: "Finalize Governance Withdraw", +} + export interface TransactionDependency { stepType: T stepName?: string @@ -95,6 +132,72 @@ export interface ClaimMetadata extends BaseMetadata { amount?: bigint } +/** + * Step type for miscellaneous on-chain actions that don't fit the + * stake/claim/unstake taxonomy — operator vault management, admin tools, etc. + * Like the unstake path, these are `msg.sender`-bound so they cannot batch via + * Multicall3 on EOA wallets; they ride the cart purely for unified UX and Safe + * multisig batching. + */ +export enum ActionStepType { + /** `ATPNonWithdrawableStaker.moveFundsBackToATP()` — operator moves staker + * contract balance back to its ATP. */ + MoveFundsBackToATP = "action:move-funds-back", + /** `StakingRegistry.registerProvider(admin, count, recipient)` — admin tool. */ + RegisterProvider = "action:register-provider", + /** `StakingRegistry.addKeysToProvider(providerId, keyStores)` — admin tool. */ + AddKeysToProvider = "action:add-keys", + /** `ATP.updateStakerOperator(operator)` — operator-management action. */ + UpdateStakerOperator = "action:update-operator", + /** `ATP.upgradeStaker(version)` — operator-management action. */ + UpgradeStaker = "action:upgrade-staker", +} + +export const ActionStepTypeName: Record = { + [ActionStepType.MoveFundsBackToATP]: "Move Funds to Vault", + [ActionStepType.RegisterProvider]: "Register Provider", + [ActionStepType.AddKeysToProvider]: "Add Keys to Provider", + [ActionStepType.UpdateStakerOperator]: "Update Operator", + [ActionStepType.UpgradeStaker]: "Upgrade Staker", +} + +export interface ActionMetadata extends BaseMetadata { + /** Target contract for the action (staker / registry / etc.) — for display. */ + contractAddress?: Address + /** Display-only ATP address for move-funds / upgrade / operator entries. */ + atpAddress?: Address + /** Provider identifier for admin add-keys / register flows. */ + providerId?: number + /** Operator address for update-operator entries. */ + operatorAddress?: Address + /** Staker version for upgrade-staker entries. */ + version?: bigint +} + +export interface UnstakeMetadata extends BaseMetadata { + /** Which validator / attester the unstake targets. Always captured at + * add-time so the cart entry's calldata is deterministic and doesn't + * depend on chain state changing between add and execute. */ + attesterAddress?: Address + /** Recipient address (rollup path); captured at add-time, NOT + * `msg.sender` — passes explicitly through calldata. */ + recipient?: Address + /** Rollup contract for rollup-path entries. */ + rollupAddress?: Address + /** Staker contract for ATP-staker / governance-path entries. */ + stakerAddress?: Address + /** Governance contract (used by FinalizeWithdrawGovernance). */ + governanceAddress?: Address + /** Rollup version for the ATP staker path (the position's stored version). */ + version?: bigint + /** Amount to unstake (governance path) or display-only stake amount (rollup/staker). */ + amount?: bigint + /** Withdrawal id used by the governance finalize call. */ + withdrawalId?: bigint + /** Provider name for cart-row display. */ + providerName?: string | null +} + export interface RawTransaction { to: Address data: `0x${string}` @@ -123,6 +226,8 @@ export type CartTransaction = | BaseCartItem<"wallet-delegation", WalletDelegationMetadata> | BaseCartItem<"wallet-direct-stake", WalletDirectStakeMetadata> | BaseCartItem<"claim", ClaimMetadata> + | BaseCartItem<"unstake", UnstakeMetadata> + | BaseCartItem<"action", ActionMetadata> export interface AddTransactionOptions { preventDuplicate?: boolean @@ -149,6 +254,11 @@ export interface TransactionCartContextType { moveUp: (id: string) => void moveDown: (id: string) => void checkTransactionInQueue: (transaction: RawTransaction) => boolean + /** Identity-based queue check by `metadata.stepType` + `stepGroupIdentifier`. + * Use this when the underlying calldata could change between renders (e.g. + * a refetched rollup version) and you'd otherwise see the queued-state + * flicker. Returns true when any cart entry shares that identity. */ + checkStepGroupInQueue: (stepType: string | number, stepGroupIdentifier: string) => boolean getTransaction: (id: string) => CartTransaction | undefined getTransactionByTx: (transaction: RawTransaction) => CartTransaction | undefined isSafe: boolean diff --git a/staking-dashboard/src/contracts/abis/Multicall3.ts b/staking-dashboard/src/contracts/abis/Multicall3.ts new file mode 100644 index 000000000..c225e826a --- /dev/null +++ b/staking-dashboard/src/contracts/abis/Multicall3.ts @@ -0,0 +1,48 @@ +/** + * Subset of the Multicall3 ABI we use for batched cart execution. + * + * Multicall3 is deployed at the same canonical address on every major EVM + * chain (`0xcA11bde05977b3631167028862bE2a173976CA11`). We only need + * `aggregate3` — it takes an array of `(target, allowFailure, callData)` and + * executes each call in order. With `allowFailure: false`, a single revert + * inside any call reverts the whole multicall, which mirrors the cart's + * existing "abort on first failure" semantic. + * + * Reference: https://github.com/mds1/multicall + */ +export const Multicall3Abi = [ + { + type: 'function', + name: 'aggregate3', + stateMutability: 'payable', + inputs: [ + { + name: 'calls', + type: 'tuple[]', + components: [ + { name: 'target', type: 'address' }, + { name: 'allowFailure', type: 'bool' }, + { name: 'callData', type: 'bytes' }, + ], + }, + ], + outputs: [ + { + name: 'returnData', + type: 'tuple[]', + components: [ + { name: 'success', type: 'bool' }, + { name: 'returnData', type: 'bytes' }, + ], + }, + ], + }, +] as const + +/** + * Multicall3's canonical deterministic deployment address. Same on every + * mainnet + most testnets the dashboard targets. Local anvil forks of mainnet + * pick this up for free; pristine anvil chains need it set via the + * `multi-rollup-test` deploy script. + */ +export const MULTICALL3_ADDRESS = '0xcA11bde05977b3631167028862bE2a173976CA11' as const diff --git a/staking-dashboard/src/hooks/staker/index.ts b/staking-dashboard/src/hooks/staker/index.ts index e007f6b81..8e70b208e 100644 --- a/staking-dashboard/src/hooks/staker/index.ts +++ b/staking-dashboard/src/hooks/staker/index.ts @@ -1,7 +1,6 @@ export * from "./useStakeWithProvider"; export * from "./useStakerBalance"; export * from "./useMultipleStakeWithProviderRewards"; -export * from "./useMoveFundsBackToATP"; export * from "./useNCStakerStatus"; export * from "./useStakerGovernanceSupport"; export * from "./types"; diff --git a/staking-dashboard/src/hooks/staker/useMoveFundsBackToATP.ts b/staking-dashboard/src/hooks/staker/useMoveFundsBackToATP.ts deleted file mode 100644 index 3b660ab8d..000000000 --- a/staking-dashboard/src/hooks/staker/useMoveFundsBackToATP.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { useWriteContract, useWaitForTransactionReceipt } from '@/hooks/useWagmiStrategy' -import { ATPNonWithdrawableStakerAbi } from '@/contracts/abis/ATPNonWithdrawableStaker' -import type { Address } from 'viem' - -/** - * Hook to move funds from staker contract back to ATP - * - * Calls ATPNonWithdrawableStaker.moveFundsBackToATP() - */ -export function useMoveFundsBackToATP(stakerAddress: Address) { - const write = useWriteContract() - - const receipt = useWaitForTransactionReceipt({ - hash: write.data, - }) - - return { - moveFunds: () => - write.writeContract({ - abi: ATPNonWithdrawableStakerAbi, - address: stakerAddress, - functionName: 'moveFundsBackToATP', - }), - txHash: write.data, - error: write.error || receipt.error, - isPending: write.isPending, - isConfirming: receipt.isLoading, - isSuccess: receipt.isSuccess, - isError: receipt.isError, - } -} diff --git a/staking-dashboard/src/hooks/stakingRegistry/index.ts b/staking-dashboard/src/hooks/stakingRegistry/index.ts index 03084f061..9a4b97f88 100644 --- a/staking-dashboard/src/hooks/stakingRegistry/index.ts +++ b/staking-dashboard/src/hooks/stakingRegistry/index.ts @@ -1,4 +1,3 @@ -export * from "./useRegisterProvider"; export * from "./useProviderRegisteredEvents"; export * from "./useStakingRegistryData"; export * from "./useProviderQueueLength"; diff --git a/staking-dashboard/src/hooks/stakingRegistry/useRegisterProvider.ts b/staking-dashboard/src/hooks/stakingRegistry/useRegisterProvider.ts deleted file mode 100644 index eefb28aaa..000000000 --- a/staking-dashboard/src/hooks/stakingRegistry/useRegisterProvider.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { contracts } from "../../contracts"; -import { - useWriteContract, - useWaitForTransactionReceipt, - useReadContract, -} from "wagmi"; -import { useState, useEffect } from "react"; -import type { Address } from "viem"; - -export function useRegisterProvider() { - const write = useWriteContract(); - const [assignedProviderId, setAssignedProviderId] = useState( - null, - ); - - // Read the current nextProviderIdentifier to predict the return value - const nextProviderIdQuery = useReadContract({ - abi: contracts.stakingRegistry.abi, - address: contracts.stakingRegistry.address, - functionName: "nextProviderIdentifier", - }); - - // Wait for the transaction receipt after sending - const receipt = useWaitForTransactionReceipt({ - hash: write.data, - }); - - // Extract provider ID from transaction logs when receipt is successful - useEffect(() => { - if (receipt.isSuccess && receipt.data?.logs) { - // Look for ProviderRegistered event in the logs - for (const log of receipt.data.logs) { - if (log.topics.length > 0 && log.topics[0]) { - // ProviderRegistered event signature - const providerRegisteredEventHash = - "0x6ce25a8d8415b24e41a9c4b30e5dfcb3b4e49b2dc84eca7b47e9b17c1ecf4bc7"; - if (log.topics[0] === providerRegisteredEventHash && log.topics[1]) { - // First indexed parameter is the provider identifier - const providerIdHex = log.topics[1]; - const providerId = BigInt(providerIdHex); - setAssignedProviderId(providerId); - break; - } - } - } - } - }, [receipt.isSuccess, receipt.data]); - - return { - registerProvider: (providerAdmin: Address) => - write.writeContract({ - abi: contracts.stakingRegistry.abi, - address: contracts.stakingRegistry.address, - functionName: "registerProvider", - args: [providerAdmin, 10, providerAdmin], - }), - - // State - txHash: write.data, - assignedProviderId, // The actual provider ID returned by the function - expectedProviderId: nextProviderIdQuery.data as bigint | undefined, // What the function will return - error: write.error || receipt.error, // Include both wallet errors and transaction errors - isPending: write.isPending, // Wallet confirmation - isConfirming: receipt.isLoading, // Waiting to be mined - isSuccess: receipt.isSuccess, // Successfully mined - isError: receipt.isError, // Failed/reverted - }; -} diff --git a/staking-dashboard/src/hooks/transactionCart/useEOAExecution.ts b/staking-dashboard/src/hooks/transactionCart/useEOAExecution.ts index 1501ae14a..1e0a42960 100644 --- a/staking-dashboard/src/hooks/transactionCart/useEOAExecution.ts +++ b/staking-dashboard/src/hooks/transactionCart/useEOAExecution.ts @@ -1,9 +1,47 @@ import { useCallback } from "react" import { useWalletClient, usePublicClient } from "wagmi" +import type { Address } from "viem" import type { CartTransaction, TransactionStatus } from "@/contexts/TransactionCartContext" +import { UnstakeStepType } from "@/contexts/TransactionCartContext" import { isUserRejection } from "@/utils/transactionCart" +import { parseContractError } from "@/utils/parseContractError" import { useAlert } from "@/contexts/AlertContext" +/** + * Per-entry execute-time safety checks. Captures invariants that can ONLY be + * verified once we know which wallet is signing (we don't know that at + * add-to-cart time, and cart entries persist across page reloads / wallet + * disconnects via localStorage). + * + * Returning `{ ok: false }` causes the executor to mark the entry as failed + * with the supplied reason — no signature ever leaves the wallet. + */ +function verifyEntryBeforeSend( + tx: CartTransaction, + walletAddress: Address, +): { ok: true } | { ok: false; reason: string } { + if ( + tx.type === "unstake" && + tx.metadata?.stepType === UnstakeStepType.InitiateWithdrawGovernanceWallet + ) { + // `Governance.initiateWithdraw(to, amount)` debits `msg.sender`'s + // governance balance and routes the eventual withdraw to `to`. If the + // entry was queued under wallet A and the user later executes from + // wallet B, B would lose funds to A. Block. + const queuedRecipient = tx.metadata.recipient + if (!queuedRecipient || queuedRecipient.toLowerCase() !== walletAddress.toLowerCase()) { + return { + ok: false, + reason: + `Recipient address baked into this entry (${queuedRecipient ?? "missing"}) ` + + `does not match the connected wallet (${walletAddress}). Remove this entry ` + + `and re-queue it from the currently connected wallet.`, + } + } + } + return { ok: true } +} + interface UseEOAExecutionProps { setTransactions: React.Dispatch> setCurrentExecutingId: React.Dispatch> @@ -30,9 +68,28 @@ export function useEOAExecution({ return } + const signerAddress = walletClient.account?.address + if (!signerAddress) { + showAlert("error", "Wallet account not available") + return + } + for (const tx of pendingTransactions) { setCurrentExecutingId(tx.id) + // Per-entry execute-time safety checks (e.g. wallet-governance + // recipient must match the signing wallet — see comment in + // verifyEntryBeforeSend for the fund-routing risk this blocks). + const safety = verifyEntryBeforeSend(tx, signerAddress) + if (!safety.ok) { + setTransactions(prev => prev.map(t => + t.id === tx.id + ? { ...t, status: 'failed' as TransactionStatus, error: safety.reason } + : t + )) + throw new Error(safety.reason) + } + try { // Mark as executing setTransactions(prev => prev.map(t => @@ -76,10 +133,13 @@ export function useEOAExecution({ )) throw new Error(`User rejected transaction: "${tx.label}"`) } else { - // Mark as failed with error for actual failures + // Normalise known contract error selectors to plain English so the + // cart panel surfaces "Exit delay has not passed yet" instead of + // raw `0xef566ee0`. Unknown errors pass through unchanged. + const friendlyError = parseContractError(errorMessage) setTransactions(prev => prev.map(t => t.id === tx.id - ? { ...t, status: 'failed' as TransactionStatus, error: errorMessage } + ? { ...t, status: 'failed' as TransactionStatus, error: friendlyError } : t )) throw error @@ -87,7 +147,10 @@ export function useEOAExecution({ } } - showAlert("success", "All transactions executed successfully") + // Note: the success toast for "all done" is fired by the dispatcher + // (`useTransactionExecution.executeAll`) once ALL segments succeed — + // not here per-segment. Otherwise a multi-segment cart would emit N + // identical "All transactions executed successfully" toasts. }, [walletClient, publicClient, setTransactions, setCurrentExecutingId, showAlert]) return { executeTransactions } diff --git a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts new file mode 100644 index 000000000..63dedd8f5 --- /dev/null +++ b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts @@ -0,0 +1,605 @@ +import { useCallback } from "react" +import { useWalletClient, usePublicClient } from "wagmi" +import { encodeFunctionData, type Hex } from "viem" +import type { CartTransaction, TransactionStatus } from "@/contexts/TransactionCartContext" +import { Multicall3Abi, MULTICALL3_ADDRESS } from "@/contracts/abis/Multicall3" +import { isUserRejection } from "@/utils/transactionCart" +import { parseContractError } from "@/utils/parseContractError" +import { useAlert } from "@/contexts/AlertContext" + +/** + * Batched-execution path that routes the whole pending-cart through a single + * `Multicall3.aggregate3` transaction. Replaces N wallet prompts with 1 for + * eligible carts. + * + * Eligibility: see `isMulticall3Eligible`. The short version is "every entry + * is a permissionless claim leg with `value: 0n`". Anything else (stake + * flows, approvals, msg.sender-bound writes) falls through to the sequential + * EOA path in the dispatcher. + * + * Failure model: `allowFailure: false` on every inner call. Any inner revert + * reverts the whole tx — same all-or-nothing semantic the cart's sequential + * path already has after our `abort on first failure` change. On revert + * every queued entry is marked `failed` (we lose per-entry attribution; the + * pre-flight simulation in `simulateBatch` exists to surface the reason + * BEFORE the user signs, so a real on-chain revert here means state changed + * between simulate and send). + */ + +interface UseMulticall3ExecutionProps { + setTransactions: React.Dispatch> + setCurrentExecutingId: React.Dispatch> +} + +/** + * Hard upper bound on the number of inner calls we'll consider for batching + * in a SINGLE multicall before chunking. Acts as a sanity bound on cart size; + * the actual per-tx size is gas-driven (see `BLOCK_GAS_FRACTION` below). + * + * Bumped from 64 to 256 to accommodate operator-side carts where one operator + * may aggregate dozens of delegators × multiple rollups; the gas-aware chunker + * splits the work safely regardless. + */ +const MAX_BATCH_SIZE = 256 + +/** + * Fraction of the chain's current block gas limit we're willing to fill in a + * single multicall, as a percentage. 75 % leaves headroom for block-fill + * variance and for an estimation that nudges slightly higher when included on + * chain. Tune per chain if needed — the value is computed against the LIVE + * block gas limit, so it self-adjusts as chains raise their limits. + */ +const BLOCK_GAS_FRACTION_PCT = 75n + +/** + * Sentinel error messages emitted by the eligibility / pre-flight checks. + * Exported so the dispatcher can match them via `=== ` instead of `.includes()` + * string-matching — a refactor that changes the wording can't silently break + * the fallback path. + */ +export const MULTICALL3_ERROR = { + NOT_DEPLOYED: 'Multicall3 not deployed on this chain', + DISPATCHER_BUG: 'useMulticall3Execution invoked with ineligible cart — dispatcher bug', + CHAIN_MISMATCH: 'Wallet and read clients are on different chains', + NO_ACCOUNT: 'No wallet account available', +} as const + +/** + * Per-entry eligibility predicate. An entry can ride through Multicall3 only + * when it's a permissionless / explicit-arg write (the claim flow) with no + * value transfer. Stake flows depend on `msg.sender` (allowances, ownership) + * and would break with Multicall3 as the caller. + */ +export function isEntryMulticall3Eligible(tx: CartTransaction): boolean { + return tx.type === 'claim' && tx.transaction.value === 0n +} + +/** + * Whole-cart eligibility check, kept for the dispatcher's "is the whole + * pending set batchable" path. With the segmenter below this is now mostly + * informational — the dispatcher iterates segments instead of branching on + * one global flag. Retained because the cart UI still calls it for the + * eligible-but-no-mix happy path detection. + * + * 1. Every entry must pass `isEntryMulticall3Eligible`. + * 2. Batch size > 1 (single-entry carts don't benefit from wrapping). + * 3. Batch size <= MAX_BATCH_SIZE (defensive bound; the gas-aware chunker + * handles real sizing, but capping here prevents pathological carts + * from queuing endless RPC estimations). + */ +export function isMulticall3Eligible(pendingTransactions: CartTransaction[]): boolean { + if (pendingTransactions.length <= 1) return false + if (pendingTransactions.length > MAX_BATCH_SIZE) return false + return pendingTransactions.every(isEntryMulticall3Eligible) +} + +/** + * An ordered plan for executing a pending cart, made of one-or-more segments. + * Each segment carries the entries that will be dispatched together to a + * single execution path: + * + * - `multicall` segments hold ≥2 contiguous eligible entries that will + * batch into one `Multicall3.aggregate3` transaction (or several chunks + * thereof if gas demands). + * - `sequential` segments hold one entry (or a single eligible entry that + * can't usefully batch by itself) and run via `useEOAExecution` one tx + * per signature. + * + * Segments preserve the cart's array order, so the cart's existing + * `dependsOn` validation (which enforces "dep must be before dependent in + * the cart") stays correct under segmentation. + */ +export type ExecutionSegment = + | { kind: 'multicall'; entries: CartTransaction[] } + | { kind: 'sequential'; entries: CartTransaction[] } + +/** + * Walk the pending cart in order and produce execution segments. Two + * strategies, in priority order: + * + * 1. **Reorder + collapse** (preferred). Stable-partition the entries into + * eligibles-first / ineligibles-second. If that ordering still respects + * every entry's `dependsOn` graph (i.e., no entry's resolved dep ends up + * AFTER it in the partitioned order), use it: one multicall segment for + * all eligibles (if ≥2), then one sequential segment per ineligible. + * This maximises batching — a cart like `[stake, claim, stake, claim]` + * becomes `[multicall(claim, claim), seq(stake), seq(stake)]`, 3 sigs + * instead of 4. + * + * 2. **Contiguous fallback**. If the partition would break a dependency + * (rare for current cart entry types — claims and stakes don't + * cross-depend — but defensive in case future flows introduce cross- + * type deps), fall back to in-order contiguous segmentation. Eligible + * runs that already sit next to each other still get batched; runs + * split by an ineligible become separate segments. + * + * Either way the resulting plan respects deps: in the partition case by + * validation, in the contiguous case by construction (we preserve the + * caller's order, which the cart's `dependsOn` validator has already + * enforced). + */ +export function planExecution(pendingTransactions: CartTransaction[]): ExecutionSegment[] { + if (pendingTransactions.length === 0) return [] + + // 1. Stable partition. + const eligibles: CartTransaction[] = [] + const ineligibles: CartTransaction[] = [] + for (const tx of pendingTransactions) { + if (isEntryMulticall3Eligible(tx)) eligibles.push(tx) + else ineligibles.push(tx) + } + const partitioned = [...eligibles, ...ineligibles] + + if (preservesDependencies(partitioned)) { + return buildSegmentsFromPartition(eligibles, ineligibles) + } + + // 2. Contiguous fallback (preserves caller order exactly). + return buildContiguousSegments(pendingTransactions) +} + +/** + * Resolve an entry's declared dependencies by matching on `stepType` + + * `stepGroupIdentifier`. Mirror of the cart's runtime resolver (which lives + * in `TransactionCartContext` and isn't exported); kept identical so the + * segmenter's dep-safety check matches what the cart's `executeAll` + * validator will accept. + */ +function resolveDependencies( + tx: CartTransaction, + pool: CartTransaction[], +): CartTransaction[] { + const metadata = tx.metadata + if (!metadata || !('dependsOn' in metadata) || !metadata.dependsOn || metadata.dependsOn.length === 0) { + return [] + } + return metadata.dependsOn + .map((dep) => + pool.find((candidate) => { + const meta = candidate.metadata + return !!meta && 'stepType' in meta && 'stepGroupIdentifier' in meta && + meta.stepType === dep.stepType && + meta.stepGroupIdentifier === dep.stepGroupIdentifier + }), + ) + .filter((dep): dep is CartTransaction => dep !== undefined) +} + +/** + * Validate that every entry's resolved dependencies come BEFORE it in the + * supplied order. This is what the cart's `executeAll` validator enforces, + * and what the dispatcher needs in order to safely run segments in plan + * order. + */ +function preservesDependencies(ordered: CartTransaction[]): boolean { + for (let i = 0; i < ordered.length; i++) { + const deps = resolveDependencies(ordered[i], ordered) + for (const dep of deps) { + const depIdx = ordered.findIndex((t) => t.id === dep.id) + if (depIdx < 0 || depIdx > i) return false + } + } + return true +} + +function buildSegmentsFromPartition( + eligibles: CartTransaction[], + ineligibles: CartTransaction[], +): ExecutionSegment[] { + const segments: ExecutionSegment[] = [] + if (eligibles.length >= 2) { + segments.push({ kind: 'multicall', entries: eligibles }) + } else if (eligibles.length === 1) { + segments.push({ kind: 'sequential', entries: eligibles }) + } + for (const tx of ineligibles) { + segments.push({ kind: 'sequential', entries: [tx] }) + } + return segments +} + +function buildContiguousSegments(pendingTransactions: CartTransaction[]): ExecutionSegment[] { + const segments: ExecutionSegment[] = [] + let batch: CartTransaction[] = [] + + const flushBatch = () => { + if (batch.length >= 2) { + segments.push({ kind: 'multicall', entries: batch }) + } else if (batch.length === 1) { + // Single eligible entry by itself — not worth wrapping in Multicall3 + // (the wrapper overhead saves no signatures). + segments.push({ kind: 'sequential', entries: batch }) + } + batch = [] + } + + for (const tx of pendingTransactions) { + if (isEntryMulticall3Eligible(tx)) { + batch.push(tx) + } else { + flushBatch() + segments.push({ kind: 'sequential', entries: [tx] }) + } + } + flushBatch() + + return segments +} + +/** + * Build the `aggregate3` calldata. `allowFailure: false` so a revert anywhere + * inside reverts the whole tx (matches the cart's existing abort-on-failure + * semantic, and keeps the cart's `dependsOn` chain meaningful — a downstream + * call can't fire when its upstream reverted). + */ +function buildAggregateCalldata(pendingTransactions: CartTransaction[]): Hex { + const calls = pendingTransactions.map((tx) => ({ + target: tx.transaction.to, + allowFailure: false, + callData: tx.transaction.data, + })) + return encodeFunctionData({ + abi: Multicall3Abi, + functionName: 'aggregate3', + args: [calls], + }) +} + +export function useMulticall3Execution({ + setTransactions, + setCurrentExecutingId, +}: UseMulticall3ExecutionProps) { + const { data: walletClient } = useWalletClient() + const publicClient = usePublicClient() + const { showAlert } = useAlert() + + /** + * Verify Multicall3 actually exists at the canonical address on the + * currently-connected chain. If we're on a chain where it isn't deployed + * (custom L2, pristine anvil) the batched send would just revert; we'd + * rather detect that here and fall back to the sequential path. + */ + const verifyMulticall3Deployed = useCallback(async (): Promise => { + if (!publicClient) return false + try { + const code = await publicClient.getCode({ address: MULTICALL3_ADDRESS }) + return !!code && code !== '0x' + } catch { + return false + } + }, [publicClient]) + + /** + * Splits the pending transactions into one-or-more chunks, each estimated + * to fit inside `BLOCK_GAS_FRACTION_PCT` of the chain's current block gas + * limit. Order is preserved across chunks so the cart's `dependsOn` + * chain stays valid (claims always come before their distribute, etc.). + * + * Strategy: + * 1. Estimate gas for the full remaining tail. If it fits, that's the + * last chunk and we're done. + * 2. Otherwise binary-search for the longest prefix that fits, emit + * it as a chunk, recurse on the remainder. + * + * If even a single entry doesn't fit (extreme edge case), we still emit + * that single entry as its own chunk — `simulateBatch` will surface the + * real revert reason at send time rather than us guessing here. + */ + const chunkByGasLimit = useCallback(async ( + pendingTransactions: CartTransaction[], + ): Promise => { + if (!publicClient || !walletClient?.account?.address) { + // Best-effort fallback: one big chunk. The downstream simulation will + // fail with a clearer error than us trying to estimate without clients. + return [pendingTransactions] + } + const account = walletClient.account.address + + const block = await publicClient.getBlock() + const blockGasLimit = block.gasLimit + const maxGasPerTx = (blockGasLimit * BLOCK_GAS_FRACTION_PCT) / 100n + + const estimateChunkGas = async (txs: CartTransaction[]): Promise => { + try { + return await publicClient.estimateContractGas({ + account, + address: MULTICALL3_ADDRESS, + abi: Multicall3Abi, + functionName: 'aggregate3', + args: [txs.map((tx) => ({ + target: tx.transaction.to, + allowFailure: false, + callData: tx.transaction.data, + }))], + value: 0n, + }) + } catch { + // Estimation reverted. Could be a doomed call or an RPC quirk. Treat + // as "didn't fit" so the binary search shrinks the chunk; if it + // shrinks all the way to 1 and STILL fails, the loop will emit that + // single entry and let simulateBatch surface the real reason. + return null + } + } + + const chunks: CartTransaction[][] = [] + let remaining = pendingTransactions + + while (remaining.length > 0) { + // Fast path: does the whole tail fit? + const wholeEstimate = await estimateChunkGas(remaining) + if (wholeEstimate !== null && wholeEstimate <= maxGasPerTx) { + chunks.push(remaining) + break + } + + // Binary search for the longest prefix that fits. We know length=1 + // is always at least attempted (worst case), and length=`remaining.length` + // didn't fit. Search the (1, remaining.length-1) interval. + let lo = 1 + let hi = remaining.length - 1 + let bestFitLen = 1 // sentinel: emit the head entry alone if nothing else fits + while (lo <= hi) { + const mid = (lo + hi) >> 1 + const gas = await estimateChunkGas(remaining.slice(0, mid)) + if (gas !== null && gas <= maxGasPerTx) { + bestFitLen = mid + lo = mid + 1 + } else { + hi = mid - 1 + } + } + + chunks.push(remaining.slice(0, bestFitLen)) + remaining = remaining.slice(bestFitLen) + } + + return chunks + }, [publicClient, walletClient]) + + /** + * Pre-flight simulation. Runs the whole multicall against the latest + * pending state. If anything inside would revert, we abort here and + * surface the reason — the user never sees a doomed wallet prompt. + * + * Returns the decoded `returnData` array on success so callers can do + * outcome verification on top. + */ + const simulateBatch = useCallback(async ( + pendingTransactions: CartTransaction[], + ): Promise< + | { ok: true; returnData: ReadonlyArray<{ success: boolean; returnData: Hex }> } + | { ok: false; reason: string } + > => { + if (!publicClient || !walletClient) return { ok: false, reason: 'No client available' } + const account = walletClient.account?.address + if (!account) return { ok: false, reason: MULTICALL3_ERROR.NO_ACCOUNT } + try { + const { result } = await publicClient.simulateContract({ + account, + address: MULTICALL3_ADDRESS, + abi: Multicall3Abi, + functionName: 'aggregate3', + args: [ + pendingTransactions.map((tx) => ({ + target: tx.transaction.to, + allowFailure: false, + callData: tx.transaction.data, + })), + ], + value: 0n, + }) + return { ok: true, returnData: result as ReadonlyArray<{ success: boolean; returnData: Hex }> } + } catch (err) { + const reason = err instanceof Error ? err.message : 'Unknown simulation error' + return { ok: false, reason } + } + }, [publicClient, walletClient]) + + /** + * Stretch outcome verification. With `allowFailure: false`, every inner + * call MUST have `success === true` by definition — simulation would have + * reverted otherwise. We assert that anyway, since a `success: false` here + * would indicate either an unsafe encoding (someone flipped allowFailure) + * or an unexpected Multicall3 deployment quirk on a custom chain. Cheap + * defence-in-depth. + * + * Future expansion: parse known event topics out of the simulation trace + * to assert "the user's balance went up by the expected amount", "the + * split is empty after distribute", etc. For now we just validate the + * structural invariant. + */ + function verifyOutcome( + returnData: ReadonlyArray<{ success: boolean; returnData: Hex }>, + expectedCount: number, + ): { ok: true } | { ok: false; reason: string } { + if (returnData.length !== expectedCount) { + return { ok: false, reason: `Simulation returned ${returnData.length} results, expected ${expectedCount}` } + } + const failedIdx = returnData.findIndex((r) => !r.success) + if (failedIdx !== -1) { + return { ok: false, reason: `Simulation reported a soft failure at entry #${failedIdx + 1}` } + } + return { ok: true } + } + + const executeTransactions = useCallback(async ( + pendingTransactions: CartTransaction[], + allTransactions: CartTransaction[], + ): Promise => { + if (!walletClient || !publicClient) { + // Don't `showAlert` here — `TransactionCartContext.executeAll` wraps + // this in a try/catch and emits the toast from `error.message`. + // Calling showAlert too would double-render the same toast. + throw new Error('Wallet client not ready') + } + + // `walletClient.account` can transiently be undefined between connect + // and the wagmi store flushing. Hard-guard before we use the address. + if (!walletClient.account?.address) { + throw new Error(MULTICALL3_ERROR.NO_ACCOUNT) + } + + // The wagmi `publicClient` and `walletClient` track the connected chain + // independently. During a chain-switch the two can briefly disagree — + // running a simulation on chain A and then `sendTransaction` landing on + // chain B would use different Multicall3 codes / nonce space / state. + // Fail closed instead. + if (publicClient.chain?.id !== walletClient.chain?.id) { + throw new Error(MULTICALL3_ERROR.CHAIN_MISMATCH) + } + + const hasExecutingTx = allTransactions.some((tx) => tx.status === 'executing' && tx.txHash) + if (hasExecutingTx) { + // See note above: outer wrapper emits the toast from `error.message`. + throw new Error('Please wait for the current transaction to complete') + } + + // Belt-and-braces guard. The dispatcher already checks eligibility — re-check here + // so this hook can never silently send a non-claim or value-bearing tx through Multicall3. + if (!isMulticall3Eligible(pendingTransactions)) { + throw new Error(MULTICALL3_ERROR.DISPATCHER_BUG) + } + + // Step 1: confirm Multicall3 lives at the canonical address on this chain. + const deployed = await verifyMulticall3Deployed() + if (!deployed) { + throw new Error(MULTICALL3_ERROR.NOT_DEPLOYED) + } + + // Step 2: split into gas-safe chunks. Most carts produce exactly one chunk; + // large operator carts split into 2-3. Order is preserved across chunks so + // dependsOn semantics still hold (e.g., claim before its distribute). + const chunks = await chunkByGasLimit(pendingTransactions) + if (chunks.length > 1) { + showAlert( + 'info', + `This batch will execute in ${chunks.length} signatures to stay under the block gas limit.`, + ) + } + + setCurrentExecutingId(null) + + // Step 3: process each chunk: simulate → mark executing → send → wait → mark complete. + // On a failure mid-flow (rejection or revert), earlier chunks stay COMPLETED on-chain, + // the current chunk is marked FAILED, and untouched downstream chunks stay PENDING so + // the user can retry by clicking Execute again. + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i] + const chunkLabel = chunks.length > 1 ? ` (${i + 1}/${chunks.length})` : '' + + // 3a. Simulate this chunk against the latest state (post-previous-chunk if any). + // On failure, mark every entry in the chunk `failed` with the reason and + // throw so the dispatcher (`runSegment` → `executeAll`) aborts. Returning + // silently here would let `executeAll` continue to subsequent segments + // and fire its terminal success toast — masking the simulation failure. + const sim = await simulateBatch(chunk) + if (!sim.ok) { + const reason = `Batch simulation failed${chunkLabel}: ${sim.reason}` + const friendlyReason = parseContractError(sim.reason) + // Mark every entry in the chunk failed so the cart panel shows + // per-entry attribution. The user-facing toast is emitted by the + // outer `TransactionCartContext.executeAll` catch from `error.message`. + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'failed' as TransactionStatus, error: friendlyReason } + : t, + )) + throw new Error(reason) + } + + // 3b. Structural outcome verification — every inner success flag true. + // Same throw-not-return rationale as 3a above. + const outcome = verifyOutcome(sim.returnData, chunk.length) + if (!outcome.ok) { + const reason = `Batch outcome check failed${chunkLabel}: ${outcome.reason}` + // See note in 3a: toast comes from the outer wrapper; this block + // only adds per-entry failure attribution. + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'failed' as TransactionStatus, error: outcome.reason } + : t, + )) + throw new Error(reason) + } + + // 3c. Mark chunk entries `executing` (untouched downstream chunks stay pending). + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'executing' as TransactionStatus } + : t, + )) + + // 3d. Send. Default gas estimation by viem; the chunker already kept us under + // a safe fraction of the block gas limit so this rarely surprises. + try { + const data = buildAggregateCalldata(chunk) + const hash = await walletClient.sendTransaction({ + to: MULTICALL3_ADDRESS, + data, + value: 0n, + }) + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) ? { ...t, txHash: hash } : t, + )) + + const receipt = await publicClient.waitForTransactionReceipt({ hash }) + if (receipt.status !== 'success') { + throw new Error(`Multicall3 transaction reverted on-chain${chunkLabel}`) + } + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'completed' as TransactionStatus, txHash: hash } + : t, + )) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error' + if (isUserRejection(errorMessage)) { + // Reset just THIS chunk to pending (downstream chunks were never touched). + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'pending' as TransactionStatus } + : t, + )) + throw new Error(`User rejected batch transaction${chunkLabel}`) + } + // Hard failure: mark THIS chunk failed and bail. Earlier chunks stay completed + // (they're on-chain); later chunks stay pending so the user can retry them. + // Normalise known contract errors so the cart panel surfaces a useful reason. + const friendlyError = parseContractError(errorMessage) + setTransactions((prev) => prev.map((t) => + chunk.some((p) => p.id === t.id) + ? { ...t, status: 'failed' as TransactionStatus, error: friendlyError } + : t, + )) + throw error + } + } + + // Note: success toast lives in the dispatcher + // (`useTransactionExecution.executeAll`) so multi-segment carts don't + // emit one toast per segment. + }, [walletClient, publicClient, setTransactions, setCurrentExecutingId, showAlert, verifyMulticall3Deployed, simulateBatch, chunkByGasLimit]) + + return { executeTransactions, isMulticall3Eligible } +} diff --git a/staking-dashboard/src/hooks/transactionCart/useTransactionExecution.ts b/staking-dashboard/src/hooks/transactionCart/useTransactionExecution.ts index 346d7f4da..9d375e682 100644 --- a/staking-dashboard/src/hooks/transactionCart/useTransactionExecution.ts +++ b/staking-dashboard/src/hooks/transactionCart/useTransactionExecution.ts @@ -3,6 +3,12 @@ import type { CartTransaction } from "@/contexts/TransactionCartContext" import { useAlert } from "@/contexts/AlertContext" import { useEOAExecution } from "./useEOAExecution" import { useSafeExecution } from "./useSafeExecution" +import { + useMulticall3Execution, + planExecution, + MULTICALL3_ERROR, + type ExecutionSegment, +} from "./useMulticall3Execution" import { useSafeApp } from "../useSafeApp" interface UseTransactionExecutionProps { @@ -12,7 +18,21 @@ interface UseTransactionExecutionProps { } /** - * Hook for executing transactions (orchestrates between EOA and Safe) + * Cart execution dispatcher. + * + * Routing priority: + * 1. Safe wallets → `useSafeExecution` (single multisig proposal containing + * every pending entry, regardless of type). + * 2. EOA → segment the pending list via `planExecution` and dispatch each + * segment in order: + * - `multicall` segments → `useMulticall3Execution` (one wallet + * signature per segment, plus internal gas-aware chunking) + * - `sequential` segments → `useEOAExecution` (one signature per entry) + * + * Mixed carts (claim + stake) split into multiple segments. Failure inside + * any segment aborts the rest — matching the cart's existing single-path + * abort-on-failure semantic. Earlier segments that already succeeded stay + * `completed`; later segments stay `pending` for the user to retry. */ export function useTransactionExecution({ transactions, @@ -23,19 +43,53 @@ export function useTransactionExecution({ const { showAlert } = useAlert() - // EOA execution hook const { executeTransactions: executeEOA } = useEOAExecution({ setTransactions, setCurrentExecutingId }) - // Safe execution hook const { executeTransactions: executeSafe } = useSafeExecution({ setTransactions }) + const { executeTransactions: executeMulticall3 } = useMulticall3Execution({ + setTransactions, + setCurrentExecutingId, + }) + + /** + * Run a single segment, with fallback for the multicall path's known + * pre-flight conditions (chain doesn't have Multicall3, chain mismatch + * during a switch, account briefly unavailable, ineligibility bug). Those + * conditions short-circuit by running the segment's entries through the + * sequential EOA path instead. Anything else (user rejection, post-sign + * revert) is the multicall path's responsibility to surface; we re-throw + * so the outer loop aborts. + */ + const runSegment = useCallback(async (segment: ExecutionSegment) => { + if (segment.kind === 'sequential') { + await executeEOA(segment.entries, transactions) + return + } + try { + await executeMulticall3(segment.entries, transactions) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + const isRecoverable = + msg === MULTICALL3_ERROR.NOT_DEPLOYED || + msg === MULTICALL3_ERROR.CHAIN_MISMATCH || + msg === MULTICALL3_ERROR.NO_ACCOUNT || + msg === MULTICALL3_ERROR.DISPATCHER_BUG + if (isRecoverable) { + showAlert('info', 'Falling back to one-by-one execution for this segment') + await executeEOA(segment.entries, transactions) + } else { + throw error + } + } + }, [executeEOA, executeMulticall3, transactions, showAlert]) + const executeAll = useCallback(async () => { - // Filter transactions that need to be executed const pendingTransactions = transactions.filter(tx => tx.status === 'pending' || tx.status === undefined ) @@ -45,13 +99,24 @@ export function useTransactionExecution({ return } - // Route to appropriate execution handler if (isSafeApp && safeSDK) { await executeSafe(pendingTransactions, transactions) - } else { - await executeEOA(pendingTransactions, transactions) + return } - }, [transactions, isSafeApp, safeSDK, executeEOA, executeSafe, showAlert]) + + // Segment the pending list; dispatch each segment to its path in order. + // For homogeneous carts (the common case) this produces exactly one + // segment and behaves identically to the previous single-path dispatch. + const segments = planExecution(pendingTransactions) + for (const segment of segments) { + await runSegment(segment) + } + + // Single success toast once every segment completed — the inner hooks + // intentionally don't emit per-segment "all done" toasts (would spam N + // identical confirmations on a multi-segment cart). + showAlert("success", "All transactions executed successfully") + }, [transactions, isSafeApp, safeSDK, executeSafe, runSegment, showAlert]) return { executeAll } } diff --git a/staking-dashboard/src/utils/actionCart.ts b/staking-dashboard/src/utils/actionCart.ts new file mode 100644 index 000000000..8d74ef16b --- /dev/null +++ b/staking-dashboard/src/utils/actionCart.ts @@ -0,0 +1,233 @@ +/** + * Cart-entry builders for miscellaneous on-chain actions that don't fit the + * stake / claim / unstake taxonomy — operator vault management, admin tools. + * + * None of these are Multicall3-batchable on EOA wallets (all are `msg.sender` + * gated). They ride the cart for unified UX and so Safe multisig users get + * native batching across mixed action sets. + */ + +import { encodeFunctionData, type Address } from "viem" +import { + ActionStepType, + type RawTransaction, +} from "@/contexts/TransactionCartContextType" +import type { CartTransaction } from "@/contexts/TransactionCartContext" +import { contracts } from "@/contracts" +import { ATPNonWithdrawableStakerAbi } from "@/contracts/abis/ATPNonWithdrawableStaker" +import { CommonATPAbi } from "@/contracts/abis/ATP" +import { MATPAbi } from "@/contracts/abis/MATP" + +export type ActionCartEntry = Omit, "id"> + +// ───────────────────────────────────────────────────────────────────────────── +// Move funds back to ATP (operator action on a staker contract) +// ───────────────────────────────────────────────────────────────────────────── + +/** `ATPNonWithdrawableStaker.moveFundsBackToATP()` raw tx. */ +export function buildMoveFundsBackToATPTx(stakerAddress: Address): RawTransaction { + return { + to: stakerAddress, + data: encodeFunctionData({ + abi: ATPNonWithdrawableStakerAbi, + functionName: "moveFundsBackToATP", + }), + value: 0n, + } +} + +interface MoveFundsBackToATPInputs { + stakerAddress: Address + /** ATP address (display-only — calldata doesn't take it). */ + atpAddress?: Address + /** Optional ATP sequential number for the cart label, e.g. "Vault #3". */ + atpLabel?: string +} + +export function buildMoveFundsBackToATPEntry(inputs: MoveFundsBackToATPInputs): ActionCartEntry { + const { stakerAddress, atpAddress, atpLabel } = inputs + return { + type: "action", + label: "Move funds to vault", + description: atpLabel, + transaction: buildMoveFundsBackToATPTx(stakerAddress), + metadata: { + stepType: ActionStepType.MoveFundsBackToATP, + stepGroupIdentifier: `action:move-funds:${stakerAddress.toLowerCase()}`, + contractAddress: stakerAddress, + atpAddress, + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Admin: register provider +// ───────────────────────────────────────────────────────────────────────────── + +interface RegisterProviderInputs { + providerAdmin: Address + /** Default 10 — matches the existing immediate-tx hook. */ + initialKeysAllowance?: number + rewardRecipient?: Address +} + +export function buildRegisterProviderTx(inputs: RegisterProviderInputs): RawTransaction { + const { providerAdmin, initialKeysAllowance = 10, rewardRecipient = providerAdmin } = inputs + return { + to: contracts.stakingRegistry.address, + data: encodeFunctionData({ + abi: contracts.stakingRegistry.abi, + functionName: "registerProvider", + args: [providerAdmin, initialKeysAllowance, rewardRecipient], + }), + value: 0n, + } +} + +export function buildRegisterProviderEntry(inputs: RegisterProviderInputs): ActionCartEntry { + const { providerAdmin } = inputs + return { + type: "action", + label: "Register provider", + description: `Admin ${providerAdmin.slice(0, 10)}…${providerAdmin.slice(-8)}`, + transaction: buildRegisterProviderTx(inputs), + metadata: { + stepType: ActionStepType.RegisterProvider, + stepGroupIdentifier: `action:register-provider:${providerAdmin.toLowerCase()}`, + contractAddress: contracts.stakingRegistry.address, + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Admin: add keys to provider +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Keystore shape accepted by `StakingRegistry.addKeysToProvider`. Mirrors the + * struct expected by the ABI — typed loosely here because each component + * passes its own attester / signature data. + */ +export interface ProviderKeyStore { + attester: Address + publicKeyG1: { x: bigint; y: bigint } + publicKeyG2: { x0: bigint; x1: bigint; y0: bigint; y1: bigint } + signature: { x: bigint; y: bigint } +} + +interface AddKeysToProviderInputs { + providerId: number + keyStores: ProviderKeyStore[] +} + +export function buildAddKeysToProviderTx(inputs: AddKeysToProviderInputs): RawTransaction { + const { providerId, keyStores } = inputs + return { + to: contracts.stakingRegistry.address, + data: encodeFunctionData({ + abi: contracts.stakingRegistry.abi, + functionName: "addKeysToProvider", + args: [BigInt(providerId), keyStores], + }), + value: 0n, + } +} + +export function buildAddKeysToProviderEntry(inputs: AddKeysToProviderInputs): ActionCartEntry { + const { providerId, keyStores } = inputs + const keyCount = keyStores.length + return { + type: "action", + label: "Add keys to provider", + description: `Provider ${providerId} · ${keyCount} key${keyCount === 1 ? "" : "s"}`, + transaction: buildAddKeysToProviderTx(inputs), + metadata: { + stepType: ActionStepType.AddKeysToProvider, + stepGroupIdentifier: `action:add-keys:${providerId}:${keyStores + .map((k) => k.attester.toLowerCase()) + .sort() + .join(",")}`, + contractAddress: contracts.stakingRegistry.address, + providerId, + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Update staker operator (`ATP.updateStakerOperator(operator)`) +// ───────────────────────────────────────────────────────────────────────────── + +interface UpdateStakerOperatorInputs { + atpAddress: Address + operator: Address +} + +export function buildUpdateStakerOperatorTx(inputs: UpdateStakerOperatorInputs): RawTransaction { + const { atpAddress, operator } = inputs + return { + to: atpAddress, + data: encodeFunctionData({ + abi: MATPAbi, + functionName: "updateStakerOperator", + args: [operator], + }), + value: 0n, + } +} + +export function buildUpdateStakerOperatorEntry(inputs: UpdateStakerOperatorInputs): ActionCartEntry { + const { atpAddress, operator } = inputs + return { + type: "action", + label: "Update operator", + description: `Set to ${operator.slice(0, 10)}…${operator.slice(-8)}`, + transaction: buildUpdateStakerOperatorTx(inputs), + metadata: { + stepType: ActionStepType.UpdateStakerOperator, + stepGroupIdentifier: `action:update-operator:${atpAddress.toLowerCase()}`, + contractAddress: atpAddress, + atpAddress, + operatorAddress: operator, + }, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Upgrade staker (`ATP.upgradeStaker(version)`) +// ───────────────────────────────────────────────────────────────────────────── + +interface UpgradeStakerInputs { + atpAddress: Address + version: bigint | number +} + +export function buildUpgradeStakerTx(inputs: UpgradeStakerInputs): RawTransaction { + const { atpAddress, version } = inputs + return { + to: atpAddress, + data: encodeFunctionData({ + abi: CommonATPAbi, + functionName: "upgradeStaker", + args: [BigInt(version)], + }), + value: 0n, + } +} + +export function buildUpgradeStakerEntry(inputs: UpgradeStakerInputs): ActionCartEntry { + const { atpAddress, version } = inputs + const versionBig = BigInt(version) + return { + type: "action", + label: "Upgrade staker", + description: `To v${versionBig.toString()}`, + transaction: buildUpgradeStakerTx(inputs), + metadata: { + stepType: ActionStepType.UpgradeStaker, + stepGroupIdentifier: `action:upgrade-staker:${atpAddress.toLowerCase()}`, + contractAddress: atpAddress, + atpAddress, + version: versionBig, + }, + } +} diff --git a/staking-dashboard/src/utils/parseContractError.ts b/staking-dashboard/src/utils/parseContractError.ts new file mode 100644 index 000000000..51ab0cd4a --- /dev/null +++ b/staking-dashboard/src/utils/parseContractError.ts @@ -0,0 +1,55 @@ +/** + * Normalise on-chain revert reasons into user-readable strings. Maps known + * Aztec staking/withdrawal error selectors + signatures to plain English so + * the user sees "Exit delay has not passed yet" instead of a raw + * `0xef566ee0`. Used by the cart's failure-reason rendering and by anyone + * else surfacing tx errors. + * + * Only normalises errors we've seen in the wild — unknown errors fall + * through to the original message (truncated if very long). + */ +export function parseContractError(error: unknown): string { + const message = error instanceof Error ? error.message : typeof error === "string" ? error : "Transaction failed" + + const errorMappings: Record = { + Staking__NotExiting: "Sequencer is not in exiting state. Initiate unstake first.", + Staking__ExitDelayNotPassed: "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", + Staking__WithdrawalDelayNotPassed: "Withdrawal delay has not passed yet. Please wait for the withdrawal period to complete.", + Staking__NotTheWithdrawer: "You are not the withdrawer for this stake. Only the original staker can initiate withdrawal.", + NotExiting: "Sequencer is not in exiting state.", + ExitDelayNotPassed: "Exit delay has not passed yet.", + NotTheWithdrawer: "Only the withdrawer can initiate withdrawal.", + "0xef566ee0": "Exit delay has not passed yet. Please wait for the withdrawal period to complete.", + } + + for (const [pattern, friendly] of Object.entries(errorMappings)) { + if (message.includes(pattern)) return friendly + } + + // `reverted with reason string "X"` + const revertMatch = message.match(/reverted with.*?["']([^"']+)["']/i) + if (revertMatch) return revertMatch[1] + + // Custom error data buried in an `error={ "data": "0x..." }` blob + const customErrorMatch = message.match(/error=\{[^}]*"data":"(0x[a-f0-9]+)"/i) + if (customErrorMatch) { + const errorData = customErrorMatch[1] + for (const [selector, friendly] of Object.entries(errorMappings)) { + if (errorData.startsWith(selector)) return friendly + } + } + + // Nonce error masking a contract revert with a known selector + if (message.includes("nonce") && message.includes("0x")) { + const selectorMatch = message.match(/0x[a-f0-9]{8}/i) + if (selectorMatch) { + const selector = selectorMatch[0].toLowerCase() + const friendly = errorMappings[selector] + if (friendly) return friendly + } + return "Transaction failed. The contract rejected the call — please check that all conditions are met." + } + + if (message.length > 200) return message.substring(0, 200) + "..." + return message || "Transaction failed" +} diff --git a/staking-dashboard/src/utils/unstakeCart.ts b/staking-dashboard/src/utils/unstakeCart.ts new file mode 100644 index 000000000..8335b9702 --- /dev/null +++ b/staking-dashboard/src/utils/unstakeCart.ts @@ -0,0 +1,328 @@ +/** + * Cart-entry builders for the unstake / finalize-withdraw flows. + * + * Three paths, two phases each: + * + * - Rollup (wallet ERC20 direct stakers) + * - Staker (ATP delegations) + * - Governance (ATP holders who deposited into governance) + * + * None of these are Multicall3-batchable on EOA wallets — `msg.sender`-bound + * authorisation gates every entry — so they all land in `sequential` segments + * of the cart's execution plan. Safe wallets still batch them natively, which + * was the motivation for cart-routing in the first place. + * + * All args required to build the tx are captured at add-to-cart time so the + * encoded calldata is deterministic. Nothing here depends on `msg.sender` + * resolving to anything in particular. + */ + +import { encodeFunctionData, type Address } from "viem" +import { + UnstakeStepType, + type RawTransaction, +} from "@/contexts/TransactionCartContextType" +import type { CartTransaction } from "@/contexts/TransactionCartContext" +import { contracts } from "@/contracts" +import { ATPWithdrawableStakerAbi } from "@/contracts/abis/ATPWithdrawableStaker" +import { ATPWithdrawableAndClaimableStakerAbi } from "@/contracts/abis/ATPWithdrawableAndClaimableStaker" + +export type UnstakeCartEntry = Omit, "id"> + +// ───────────────────────────────────────────────────────────────────────────── +// Rollup path (wallet ERC20 direct stakers) +// ───────────────────────────────────────────────────────────────────────────── + +/** `Rollup.initiateWithdraw(attester, recipient)` raw tx. */ +export function buildRollupInitiateWithdrawTx( + rollupAddress: Address, + attester: Address, + recipient: Address, +): RawTransaction { + return { + to: rollupAddress, + data: encodeFunctionData({ + abi: contracts.rollup.abi, + functionName: "initiateWithdraw", + args: [attester, recipient], + }), + value: 0n, + } +} + +/** `Rollup.finalizeWithdraw(attester)` raw tx. */ +export function buildRollupFinalizeWithdrawTx( + rollupAddress: Address, + attester: Address, +): RawTransaction { + return { + to: rollupAddress, + data: encodeFunctionData({ + abi: contracts.rollup.abi, + functionName: "finalizeWithdraw", + args: [attester], + }), + value: 0n, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// ATP staker path (delegations through an ATP) +// ───────────────────────────────────────────────────────────────────────────── + +/** `Staker.initiateWithdraw(version, attester)` raw tx. */ +export function buildStakerInitiateWithdrawTx( + stakerAddress: Address, + version: bigint, + attester: Address, +): RawTransaction { + return { + to: stakerAddress, + data: encodeFunctionData({ + abi: ATPWithdrawableStakerAbi, + functionName: "initiateWithdraw", + args: [version, attester], + }), + value: 0n, + } +} + +/** `Staker.finalizeWithdraw(version, attester)` raw tx. */ +export function buildStakerFinalizeWithdrawTx( + stakerAddress: Address, + version: bigint, + attester: Address, +): RawTransaction { + return { + to: stakerAddress, + data: encodeFunctionData({ + abi: ATPWithdrawableStakerAbi, + functionName: "finalizeWithdraw", + args: [version, attester], + }), + value: 0n, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Governance path (initiate is on the Staker contract, finalize is on Governance) +// ───────────────────────────────────────────────────────────────────────────── + +/** `Staker.initiateWithdrawFromGovernance(amount)` raw tx. */ +export function buildGovernanceInitiateWithdrawTx( + stakerAddress: Address, + amount: bigint, +): RawTransaction { + return { + to: stakerAddress, + data: encodeFunctionData({ + abi: ATPWithdrawableAndClaimableStakerAbi, + functionName: "initiateWithdrawFromGovernance", + args: [amount], + }), + value: 0n, + } +} + +/** + * `Governance.initiateWithdraw(to, amount)` raw tx — direct-deposit ERC20 + * holders (no Staker contract in between). + */ +export function buildGovernanceWalletInitiateWithdrawTx( + to: Address, + amount: bigint, +): RawTransaction { + return { + to: contracts.governance.address, + data: encodeFunctionData({ + abi: contracts.governance.abi, + functionName: "initiateWithdraw", + args: [to, amount], + }), + value: 0n, + } +} + +/** `Governance.finalizeWithdraw(withdrawalId)` raw tx. */ +export function buildGovernanceFinalizeWithdrawTx(withdrawalId: bigint): RawTransaction { + return { + to: contracts.governance.address, + data: encodeFunctionData({ + abi: contracts.governance.abi, + functionName: "finalizeWithdraw", + args: [withdrawalId], + }), + value: 0n, + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Cart-entry builders — wrap a raw tx + metadata into the `Omit` +// shape the cart's `addTransaction` expects. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * Per-position group identifier, used both for dedupe at the cart level and + * for any future `dependsOn` wiring. Distinct per attester / contract pair + * so two positions on the same attester but different rollups don't collide. + */ +function positionGroup(attester: Address, contract: Address): string { + return `unstake:${attester.toLowerCase()}:${contract.toLowerCase()}` +} + +interface RollupUnstakeInputs { + rollupAddress: Address + attester: Address + recipient: Address + /** Display-only stake amount. */ + amount?: bigint + providerName?: string | null +} + +export function buildRollupInitiateWithdrawEntry(inputs: RollupUnstakeInputs): UnstakeCartEntry { + const { rollupAddress, attester, recipient, amount, providerName } = inputs + return { + type: "unstake", + label: "Initiate unstake", + description: providerName ? `From ${providerName}` : undefined, + transaction: buildRollupInitiateWithdrawTx(rollupAddress, attester, recipient), + metadata: { + stepType: UnstakeStepType.InitiateWithdrawRollup, + stepGroupIdentifier: positionGroup(attester, rollupAddress), + attesterAddress: attester, + recipient, + rollupAddress, + amount, + providerName, + }, + } +} + +export function buildRollupFinalizeWithdrawEntry(inputs: Omit): UnstakeCartEntry { + const { rollupAddress, attester, amount, providerName } = inputs + return { + type: "unstake", + label: "Finalize unstake", + description: providerName ? `From ${providerName}` : undefined, + transaction: buildRollupFinalizeWithdrawTx(rollupAddress, attester), + metadata: { + stepType: UnstakeStepType.FinalizeWithdrawRollup, + stepGroupIdentifier: positionGroup(attester, rollupAddress), + attesterAddress: attester, + rollupAddress, + amount, + providerName, + }, + } +} + +interface StakerUnstakeInputs { + stakerAddress: Address + version: bigint + attester: Address + amount?: bigint + providerName?: string | null +} + +export function buildStakerInitiateWithdrawEntry(inputs: StakerUnstakeInputs): UnstakeCartEntry { + const { stakerAddress, version, attester, amount, providerName } = inputs + return { + type: "unstake", + label: "Initiate unstake", + description: providerName ? `From ${providerName}` : undefined, + transaction: buildStakerInitiateWithdrawTx(stakerAddress, version, attester), + metadata: { + stepType: UnstakeStepType.InitiateWithdrawStaker, + stepGroupIdentifier: positionGroup(attester, stakerAddress), + attesterAddress: attester, + stakerAddress, + version, + amount, + providerName, + }, + } +} + +export function buildStakerFinalizeWithdrawEntry(inputs: StakerUnstakeInputs): UnstakeCartEntry { + const { stakerAddress, version, attester, amount, providerName } = inputs + return { + type: "unstake", + label: "Finalize unstake", + description: providerName ? `From ${providerName}` : undefined, + transaction: buildStakerFinalizeWithdrawTx(stakerAddress, version, attester), + metadata: { + stepType: UnstakeStepType.FinalizeWithdrawStaker, + stepGroupIdentifier: positionGroup(attester, stakerAddress), + attesterAddress: attester, + stakerAddress, + version, + amount, + providerName, + }, + } +} + +interface GovernanceInitiateInputs { + stakerAddress: Address + amount: bigint +} + +export function buildGovernanceInitiateWithdrawEntry(inputs: GovernanceInitiateInputs): UnstakeCartEntry { + const { stakerAddress, amount } = inputs + return { + type: "unstake", + label: "Initiate governance withdraw", + description: `${amount.toString()} (raw)`, + transaction: buildGovernanceInitiateWithdrawTx(stakerAddress, amount), + metadata: { + stepType: UnstakeStepType.InitiateWithdrawGovernance, + stepGroupIdentifier: `unstake:gov:${stakerAddress.toLowerCase()}:${amount.toString()}`, + stakerAddress, + amount, + }, + } +} + +interface GovernanceWalletInitiateInputs { + to: Address + amount: bigint +} + +export function buildGovernanceWalletInitiateWithdrawEntry( + inputs: GovernanceWalletInitiateInputs, +): UnstakeCartEntry { + const { to, amount } = inputs + return { + type: "unstake", + label: "Initiate governance withdraw", + description: `${amount.toString()} (raw) to ${to.slice(0, 10)}…${to.slice(-8)}`, + transaction: buildGovernanceWalletInitiateWithdrawTx(to, amount), + metadata: { + stepType: UnstakeStepType.InitiateWithdrawGovernanceWallet, + stepGroupIdentifier: `unstake:gov-wallet:${to.toLowerCase()}:${amount.toString()}`, + governanceAddress: contracts.governance.address, + recipient: to, + amount, + }, + } +} + +interface GovernanceFinalizeInputs { + withdrawalId: bigint +} + +export function buildGovernanceFinalizeWithdrawEntry(inputs: GovernanceFinalizeInputs): UnstakeCartEntry { + const { withdrawalId } = inputs + return { + type: "unstake", + label: "Finalize governance withdraw", + description: `Withdrawal #${withdrawalId.toString()}`, + transaction: buildGovernanceFinalizeWithdrawTx(withdrawalId), + metadata: { + stepType: UnstakeStepType.FinalizeWithdrawGovernance, + stepGroupIdentifier: `unstake:gov:finalize:${withdrawalId.toString()}`, + governanceAddress: contracts.governance.address, + withdrawalId, + }, + } +}