From 095e9d76d407389b26497aaebab9216903220ab9 Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 22:23:18 +0400 Subject: [PATCH 1/4] feat: add multicall support for supported txs --- .../ATPDetailsModal/WithdrawalActions.tsx | 333 ++++------ .../WithdrawFromGovernanceModal.tsx | 145 ++--- .../TransactionCart/MulticallBatchHeader.tsx | 62 ++ .../TransactionCartDetailsExpanded.tsx | 82 ++- .../TransactionCartExpanded.tsx | 92 ++- .../WalletWithdrawalActions.tsx | 267 +++----- .../src/contexts/TransactionCartContext.tsx | 28 +- .../contexts/TransactionCartContextType.ts | 69 ++- .../src/contracts/abis/Multicall3.ts | 48 ++ .../hooks/transactionCart/useEOAExecution.ts | 69 ++- .../transactionCart/useMulticall3Execution.ts | 582 ++++++++++++++++++ .../useTransactionExecution.ts | 81 ++- .../src/utils/parseContractError.ts | 55 ++ staking-dashboard/src/utils/unstakeCart.ts | 328 ++++++++++ 14 files changed, 1708 insertions(+), 533 deletions(-) create mode 100644 staking-dashboard/src/components/TransactionCart/MulticallBatchHeader.tsx create mode 100644 staking-dashboard/src/contracts/abis/Multicall3.ts create mode 100644 staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts create mode 100644 staking-dashboard/src/utils/parseContractError.ts create mode 100644 staking-dashboard/src/utils/unstakeCart.ts 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/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/TransactionCartDetailsExpanded.tsx b/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx index 62c30062e..ac5e1f7d7 100644 --- a/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx +++ b/staking-dashboard/src/components/TransactionCart/TransactionCartDetailsExpanded.tsx @@ -1,5 +1,5 @@ import type { CartTransaction } from "@/contexts/TransactionCartContext" -import { ClaimStepTypeName } from "@/contexts/TransactionCartContext" +import { ClaimStepTypeName, UnstakeStepTypeName } from "@/contexts/TransactionCartContext" import { CopyButton } from "@/components/CopyButton/CopyButton" import { Icon } from "@/components/Icon" import { openTxInExplorer } from "@/utils/explorerUtils" @@ -192,6 +192,86 @@ export const TransactionCartDetailsExpanded = ({ transaction }: TransactionCartD )} )} + {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 === "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/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..b7bacf056 100644 --- a/staking-dashboard/src/contexts/TransactionCartContext.tsx +++ b/staking-dashboard/src/contexts/TransactionCartContext.tsx @@ -13,13 +13,14 @@ import type { SelfStakeMetadata, WalletDirectStakeMetadata, ClaimMetadata, + UnstakeMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions, TransactionCartContextType } from "./TransactionCartContextType" -import { ClaimStepType, ClaimStepTypeName } from "./TransactionCartContextType" +import { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName } from "./TransactionCartContextType" // Re-export types for backwards compatibility export type { @@ -28,13 +29,14 @@ export type { SelfStakeMetadata, WalletDirectStakeMetadata, ClaimMetadata, + UnstakeMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions } -export { ClaimStepType, ClaimStepTypeName } +export { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName } const TransactionCartContext = createContext(undefined) @@ -85,6 +87,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 +347,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..9c3aa552d 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" /** * 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,30 @@ export interface ClaimMetadata extends BaseMetadata { amount?: 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 +184,7 @@ export type CartTransaction = | BaseCartItem<"wallet-delegation", WalletDelegationMetadata> | BaseCartItem<"wallet-direct-stake", WalletDirectStakeMetadata> | BaseCartItem<"claim", ClaimMetadata> + | BaseCartItem<"unstake", UnstakeMetadata> export interface AddTransactionOptions { preventDuplicate?: boolean @@ -149,6 +211,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/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..143a77fc9 --- /dev/null +++ b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts @@ -0,0 +1,582 @@ +import { useCallback } from "react" +import { useWalletClient, usePublicClient } from "wagmi" +import { encodeFunctionData, type Address, 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) { + showAlert('error', 'Wallet client not ready') + return + } + + // `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) { + showAlert('info', 'Please wait for the current transaction to complete') + return + } + + // 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). + const sim = await simulateBatch(chunk) + if (!sim.ok) { + showAlert('error', `Batch simulation failed${chunkLabel}: ${sim.reason}`) + return + } + + // 3b. Structural outcome verification — every inner success flag true. + const outcome = verifyOutcome(sim.returnData, chunk.length) + if (!outcome.ok) { + showAlert('error', `Batch outcome check failed${chunkLabel}: ${outcome.reason}`) + return + } + + // 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/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, + }, + } +} From 072d835e3dc80f453edaf94c7744147f03b9cabf Mon Sep 17 00:00:00 2001 From: Koen Date: Tue, 12 May 2026 22:27:12 +0400 Subject: [PATCH 2/4] fix: add pending withdrawals to cart for batching --- .../Governance/PendingWithdrawals.tsx | 98 ++++++++----------- 1 file changed, 42 insertions(+), 56 deletions(-) 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} From 5b941582151e9712aad00d0faf34e0b5d2b18a4e Mon Sep 17 00:00:00 2001 From: Koen Date: Wed, 13 May 2026 12:11:40 +0400 Subject: [PATCH 3/4] fix: Use cart for all txs --- .../ATPDetailsStakerBalance.tsx | 70 +++--- .../ATPDetailsStakerManagement.tsx | 73 +++--- .../ATPDetailsTechnicalInfo.tsx | 77 +++--- .../src/components/AdminTools/AdminTools.tsx | 121 +++------ .../SetOperatorModal/SetOperatorModal.tsx | 63 ++--- .../TransactionCart/TransactionCart.tsx | 2 +- .../TransactionCartDetailsExpanded.tsx | 44 +++- .../UpgradeStakerModal/UpgradeStakerModal.tsx | 97 +++----- .../src/contexts/TransactionCartContext.tsx | 6 +- .../contexts/TransactionCartContextType.ts | 45 +++- staking-dashboard/src/hooks/staker/index.ts | 1 - .../src/hooks/staker/useMoveFundsBackToATP.ts | 31 --- .../src/hooks/stakingRegistry/index.ts | 1 - .../stakingRegistry/useRegisterProvider.ts | 68 ----- .../transactionCart/useMulticall3Execution.ts | 2 +- staking-dashboard/src/utils/actionCart.ts | 233 ++++++++++++++++++ 16 files changed, 515 insertions(+), 419 deletions(-) delete mode 100644 staking-dashboard/src/hooks/staker/useMoveFundsBackToATP.ts delete mode 100644 staking-dashboard/src/hooks/stakingRegistry/useRegisterProvider.ts create mode 100644 staking-dashboard/src/utils/actionCart.ts 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/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/SetOperatorModal/SetOperatorModal.tsx b/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx index e25fa54be..28b93d7ec 100644 --- a/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx +++ b/staking-dashboard/src/components/SetOperatorModal/SetOperatorModal.tsx @@ -1,10 +1,11 @@ -import { useState, useEffect } from "react"; +import { useState } from "react"; import type { Address } from "viem"; import { useAccount } from "wagmi"; import { formatEther } from "viem"; import StepSetOperator from "../StepSetOperator/StepSetOperator"; import type { MATPData } from "../../hooks/atp/matp"; -import { useUpdateStakerOperator } from "../../hooks/atp/useUpdateStakerOperator"; +import { useTransactionCart } from "@/contexts/TransactionCartContext"; +import { buildUpdateStakerOperatorEntry } from "@/utils/actionCart"; import { formatAddress } from "../../utils/formatAddress"; import styles from "./SetOperatorModal.module.css"; @@ -22,34 +23,7 @@ export default function SetOperatorModal({ }: SetOperatorModalProps) { const { address } = useAccount(); const [isCompleted, setIsCompleted] = useState(false); - - // Initialize the updateStakerOperator hook - const updateOperatorHook = useUpdateStakerOperator( - atp?.atpAddress as Address, - ); - - // Monitor transaction states - useEffect(() => { - if (updateOperatorHook.isSuccess) { - console.log("✅ Set operator transaction successful!"); - console.log("Transaction Hash:", updateOperatorHook.txHash); - console.log("Transaction Status: success"); - setIsCompleted(true); - } else if (updateOperatorHook.error) { - console.error("❌ Set operator transaction failed:"); - console.error("Error:", updateOperatorHook.error.message); - } - }, [ - updateOperatorHook.isSuccess, - updateOperatorHook.error, - updateOperatorHook.txHash, - ]); - - useEffect(() => { - if (updateOperatorHook.isPending) { - console.log("⏳ Set operator transaction is pending..."); - } - }, [updateOperatorHook.isPending]); + const { addTransaction, openCart } = useTransactionCart(); const getTypeName = (atp: MATPData) => { switch (atp.type) { @@ -64,22 +38,18 @@ export default function SetOperatorModal({ if (!isOpen || !atp) return null; - const handleSetOperator = async (operatorAddress: Address) => { + const handleSetOperator = (operatorAddress: Address) => { if (!address || !atp) return; - try { - console.log( - "Sending updateStakerOperator transaction with operator:", - operatorAddress, - ); - - // Call the real updateStakerOperator function - updateOperatorHook.updateStakerOperator(operatorAddress); - - // Transaction state monitoring is handled in useEffect hooks - } catch (error) { - console.error("❌ Error setting operator:", error); - } + addTransaction( + buildUpdateStakerOperatorEntry({ + atpAddress: atp.atpAddress as Address, + operator: operatorAddress, + }), + { preventDuplicate: true }, + ); + setIsCompleted(true); + openCart(); }; const handleClose = () => { @@ -164,8 +134,7 @@ export default function SetOperatorModal({ - Operator successfully set! You can now close this modal. + Operator queued in batch. Open the cart to execute. )} 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 === "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/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/contexts/TransactionCartContext.tsx b/staking-dashboard/src/contexts/TransactionCartContext.tsx index b7bacf056..af5a472b6 100644 --- a/staking-dashboard/src/contexts/TransactionCartContext.tsx +++ b/staking-dashboard/src/contexts/TransactionCartContext.tsx @@ -14,13 +14,14 @@ import type { WalletDirectStakeMetadata, ClaimMetadata, UnstakeMetadata, + ActionMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions, TransactionCartContextType } from "./TransactionCartContextType" -import { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName } from "./TransactionCartContextType" +import { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName, ActionStepType, ActionStepTypeName } from "./TransactionCartContextType" // Re-export types for backwards compatibility export type { @@ -30,13 +31,14 @@ export type { WalletDirectStakeMetadata, ClaimMetadata, UnstakeMetadata, + ActionMetadata, RawTransaction, TransactionStatus, CartTransaction, AddTransactionOptions } -export { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName } +export { ClaimStepType, ClaimStepTypeName, UnstakeStepType, UnstakeStepTypeName, ActionStepType, ActionStepTypeName } const TransactionCartContext = createContext(undefined) diff --git a/staking-dashboard/src/contexts/TransactionCartContextType.ts b/staking-dashboard/src/contexts/TransactionCartContextType.ts index 9c3aa552d..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" | "unstake" +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 @@ -132,6 +132,48 @@ 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 @@ -185,6 +227,7 @@ export type CartTransaction = | BaseCartItem<"wallet-direct-stake", WalletDirectStakeMetadata> | BaseCartItem<"claim", ClaimMetadata> | BaseCartItem<"unstake", UnstakeMetadata> + | BaseCartItem<"action", ActionMetadata> export interface AddTransactionOptions { preventDuplicate?: boolean 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/useMulticall3Execution.ts b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts index 143a77fc9..a4fa57161 100644 --- a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts +++ b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts @@ -1,6 +1,6 @@ import { useCallback } from "react" import { useWalletClient, usePublicClient } from "wagmi" -import { encodeFunctionData, type Address, type Hex } from "viem" +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" 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, + }, + } +} From c71266f6088245d0211b4b12f6e0ed53ef71f7e4 Mon Sep 17 00:00:00 2001 From: Robert Brada <44506010+robertbrada@users.noreply.github.com> Date: Wed, 13 May 2026 12:30:14 +0200 Subject: [PATCH 4/4] fix: don't claim success when a multicall batch fails before sending (#71) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: throw instead of return on multicall pre-send failures Simulation and outcome-check failures inside the Multicall3 execution path called showAlert and returned. The dispatcher's executeAll loop treated the silent return as success, continued past the segment, and fired its terminal "All transactions executed successfully" toast — directly contradicting the error toast the user had just seen. Convert the four pre-send bailout paths to throw. For the two cases that fail after the cart has accepted entries (simulation, outcome check), mark every entry in the affected chunk as failed with the parsed reason before throwing so per-entry attribution survives in the cart panel. * fix: restore user-facing alerts on multicall pre-flight failures The prior commit converted four return paths to throws, but two of them ("Wallet client not ready" and "hasExecutingTx") dropped their showAlert calls. Because nothing higher up catches the thrown error, the user saw nothing — silent failure replaced the prior toast. Restore the alerts alongside the throw, matching the pattern already used for the simulation- and outcome-check branches. * fix: drop redundant showAlert calls — outer wrapper already emits toast `TransactionCartContext.executeAll` already wraps `executeAllTransactions()` in a try/catch that fires `showAlert("error", error.message)` on any rejection. The showAlert calls added in the prior commit were therefore duplicating the wrapper's toast (and in the `hasExecutingTx` branch, the two toasts even disagreed on type — info vs. error). Remove the local showAlert at every site that now throws. Keep: - the throws themselves (still required to abort `executeAll` and prevent the false-success toast), - the `setTransactions(failed)` blocks in the simulation / outcome-check branches (they add per-entry attribution the wrapper can't reconstruct), - the chunk-count info toast above the chunk loop (legitimate, not duplicated by the wrapper). `error.message` carries the chunk-labelled reason verbatim, so the toast the user sees is identical to what the local showAlert was emitting. --- .../transactionCart/useMulticall3Execution.ts | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts index a4fa57161..63dedd8f5 100644 --- a/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts +++ b/staking-dashboard/src/hooks/transactionCart/useMulticall3Execution.ts @@ -448,8 +448,10 @@ export function useMulticall3Execution({ allTransactions: CartTransaction[], ): Promise => { if (!walletClient || !publicClient) { - showAlert('error', 'Wallet client not ready') - return + // 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 @@ -469,8 +471,8 @@ export function useMulticall3Execution({ const hasExecutingTx = allTransactions.some((tx) => tx.status === 'executing' && tx.txHash) if (hasExecutingTx) { - showAlert('info', 'Please wait for the current transaction to complete') - return + // 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 @@ -507,17 +509,38 @@ export function useMulticall3Execution({ 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) { - showAlert('error', `Batch simulation failed${chunkLabel}: ${sim.reason}`) - return + 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) { - showAlert('error', `Batch outcome check failed${chunkLabel}: ${outcome.reason}`) - return + 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).