From 353914ba6aff2186ab8bc6ed2c551d28a16b994e Mon Sep 17 00:00:00 2001 From: Anto Date: Tue, 19 May 2026 20:52:27 +0200 Subject: [PATCH 1/3] fix(safe): handle Safe-over-WalletConnect schedule/execute/cancel flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detect Safe via WC peer metadata (name "Safe{Wallet}" or url app.safe.global), reading the live connector instance from useConnections() rather than the shallow useAccount() descriptor. - Skip waitForTransactionReceipt when Safe is connected — the returned hash is a Safe-tx-hash, not an Ethereum tx hash, so the receipt poll hangs forever. Show the user a "confirm in Safe" toast instead. - Add 5-minute timeout to waitForTransactionReceipt as a safety net for EOAs (and for any Safe-like wallet we may not detect). - Latch the role/minDelay reads in useTimelockRoles so a flaky RPC refetch doesn't make the New operation / Execute / Cancel buttons flicker out on mainnet. - 26 new tests covering Safe detection, the latch, and connectors lib. --- package.json | 2 +- src/components/OperationCard.tsx | 51 +++++++-- src/components/OperationForm.tsx | 33 +++++- src/hooks/useIsSafeWallet.test.tsx | 156 ++++++++++++++++++++++++++++ src/hooks/useIsSafeWallet.ts | 61 +++++++++++ src/hooks/useTimelockRoles.test.tsx | 146 ++++++++++++++++++++++++++ src/hooks/useTimelockRoles.ts | 66 +++++++++--- src/lib/connectors.test.ts | 65 ++++++++++++ src/lib/connectors.ts | 44 ++++++++ src/pages/NewOperation.tsx | 53 +++------- src/pages/Operations.tsx | 8 +- 11 files changed, 619 insertions(+), 66 deletions(-) create mode 100644 src/hooks/useIsSafeWallet.test.tsx create mode 100644 src/hooks/useIsSafeWallet.ts create mode 100644 src/hooks/useTimelockRoles.test.tsx create mode 100644 src/lib/connectors.test.ts create mode 100644 src/lib/connectors.ts diff --git a/package.json b/package.json index e881d32..e8bf952 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "timelock-ui", "private": true, - "version": "1.1.0", + "version": "1.1.1", "description": "Minimal UI to operate OpenZeppelin TimelockController instances", "type": "module", "packageManager": "pnpm@11.1.1", diff --git a/src/components/OperationCard.tsx b/src/components/OperationCard.tsx index bc10d65..80fc430 100644 --- a/src/components/OperationCard.tsx +++ b/src/components/OperationCard.tsx @@ -7,6 +7,8 @@ import { useOperationState } from '../hooks/useOperationState' import { useTimelockRoles } from '../hooks/useTimelockRoles' import { timelockAbi } from '../abis/timelock' import { OperationState, shortHex, explorerTxUrl, explorerAddressUrl, hashOperation } from '../lib/timelock' +import { RECEIPT_TIMEOUT_MS } from '../lib/connectors' +import { useIsSafeWallet } from '../hooks/useIsSafeWallet' import type { StoredOperation } from '../lib/storage' interface Props { @@ -23,6 +25,7 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr const [saltError, setSaltError] = useState(null) const { address: userAddress } = useAccount() const client = usePublicClient({ chainId: operation.chainId }) + const safeFlow = useIsSafeWallet() const { data: onChain, isLoading, refetch } = useOperationState( operation.timelockAddress, @@ -77,10 +80,26 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr chainId: operation.chainId, }) onPatched(operation.id, { executeTxHash: hash }) + + if (safeFlow) { + onToast({ + message: 'Execute sent to Safe. Confirm signatures at app.safe.global to broadcast on-chain.', + type: 'info', + }) + return + } + setTxPending(true) - await client?.waitForTransactionReceipt({ hash }) + try { + await client?.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }) + onToast({ message: 'Operation executed successfully', type: 'success' }) + } catch { + onToast({ + message: 'Execute submitted but receipt not seen yet. Check the explorer in a moment.', + type: 'info', + }) + } refetch() - onToast({ message: 'Operation executed successfully', type: 'success' }) } catch (e: any) { onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' }) } finally { @@ -98,10 +117,26 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr chainId: operation.chainId, }) onPatched(operation.id, { cancelTxHash: hash }) + + if (safeFlow) { + onToast({ + message: 'Cancel sent to Safe. Confirm signatures at app.safe.global to broadcast on-chain.', + type: 'info', + }) + return + } + setTxPending(true) - await client?.waitForTransactionReceipt({ hash }) + try { + await client?.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }) + onToast({ message: 'Operation cancelled', type: 'info' }) + } catch { + onToast({ + message: 'Cancel submitted but receipt not seen yet. Check the explorer in a moment.', + type: 'info', + }) + } refetch() - onToast({ message: 'Operation cancelled', type: 'info' }) } catch (e: any) { onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' }) } finally { @@ -165,7 +200,9 @@ export function OperationCard({ operation, explorerUrl, onPatched, onToast }: Pr ? : } - {isPending ? 'Confirm in wallet…' : txPending ? 'Waiting…' : 'Execute'} + {isPending + ? safeFlow ? 'Sending to Safe…' : 'Confirm in wallet…' + : txPending ? 'Waiting…' : 'Execute'} ) : ( )}