Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
65 changes: 57 additions & 8 deletions src/components/OperationCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
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 {
Expand All @@ -23,6 +25,7 @@
const [saltError, setSaltError] = useState<string | null>(null)
const { address: userAddress } = useAccount()
const client = usePublicClient({ chainId: operation.chainId })
const safeFlow = useIsSafeWallet()

const { data: onChain, isLoading, refetch } = useOperationState(
operation.timelockAddress,
Expand Down Expand Up @@ -76,12 +79,34 @@
value: BigInt(operation.value),
chainId: operation.chainId,
})
onPatched(operation.id, { executeTxHash: hash })
// Only persist a real Ethereum tx hash. The Safe flow returns a
// Safe-tx-hash that would render as a broken explorer link; the
// on-chain hash will be picked up by the chain scan after signers
// execute the multisig.
if (!safeFlow) {
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) {

Check failure on line 109 in src/components/OperationCard.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' })
} finally {
setTxPending(false)
Expand All @@ -97,12 +122,32 @@
args: [operation.id],
chainId: operation.chainId,
})
onPatched(operation.id, { cancelTxHash: hash })
// See execute: avoid storing a Safe-tx-hash in a field rendered as an
// explorer link.
if (!safeFlow) {
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) {

Check failure on line 150 in src/components/OperationCard.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
onToast({ message: e?.shortMessage ?? e?.message ?? 'Unknown error', type: 'error' })
} finally {
setTxPending(false)
Expand Down Expand Up @@ -165,7 +210,9 @@
? <Loader2 size={14} className="animate-spin" />
: <Play size={14} />
}
{isPending ? 'Confirm in wallet…' : txPending ? 'Waiting…' : 'Execute'}
{isPending
? safeFlow ? 'Sending to Safe…' : 'Confirm in wallet…'
: txPending ? 'Waiting…' : 'Execute'}
</button>
) : (
<button
Expand All @@ -188,7 +235,9 @@
? <Loader2 size={14} className="animate-spin" />
: <X size={14} />
}
{isPending ? 'Confirm…' : txPending ? 'Waiting…' : 'Cancel'}
{isPending
? safeFlow ? 'Sending to Safe…' : 'Confirm…'
: txPending ? 'Waiting…' : 'Cancel'}
</button>
)}
<button
Expand Down
43 changes: 38 additions & 5 deletions src/components/OperationForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,23 @@ import { timelockAbi } from '../abis/timelock'
import { hashOperation, generateSalt, formatDelay } from '../lib/timelock'
import { parseMethodSignature, encodeCalldata, type ParsedParam } from '../lib/abi-parser'
import { upsertOperation, type StoredOperation } from '../lib/storage'
import { RECEIPT_TIMEOUT_MS } from '../lib/connectors'
import { useIsSafeWallet } from '../hooks/useIsSafeWallet'
import { Toast } from './Toast'

interface Props {
timelockAddress: `0x${string}`
chainId: number
minDelay: bigint
onScheduled: () => void
onScheduled: (opts?: { needsSafeSignatures?: boolean; receiptPending?: boolean }) => void
onCancel: () => void
}

export function OperationForm({ timelockAddress, chainId, minDelay, onScheduled, onCancel }: Props) {
const { address: userAddress } = useAccount()
const client = usePublicClient()
const { writeContractAsync, isPending } = useWriteContract()
const safeFlow = useIsSafeWallet()

// ─── Form state ───────────────────────────────────────────────────────────
const [target, setTarget] = useState('')
Expand Down Expand Up @@ -116,12 +119,34 @@ export function OperationForm({ timelockAddress, chainId, minDelay, onScheduled,
source: 'local',
methodSignature: rawMode ? undefined : signature,
scheduledAt: Date.now(),
scheduleTxHash: hash,
// Only store as scheduleTxHash if it's a real Ethereum tx hash. Safe
// returns a Safe-tx-hash which would produce a broken explorer link;
// the on-chain tx hash gets populated later by the chain scan once
// signers execute the multisig.
scheduleTxHash: safeFlow ? undefined : hash,
}

upsertOperation(op)
await client?.waitForTransactionReceipt({ hash })
onScheduled()

// Safe returns a Safe-tx-hash, not an Ethereum tx hash, so waiting for
// a receipt would hang forever. Skip the wait and let the user track
// the multisig in the Safe UI. The operation is already saved locally,
// and the chain scan will pick up the on-chain tx once signers execute.
if (safeFlow) {
Comment thread
AntoTG marked this conversation as resolved.
onScheduled({ needsSafeSignatures: true })
return
}

// Wait for the schedule TX to confirm. On timeout or transient RPC
// error we don't *know* whether the TX landed — it may still confirm,
// it may have been dropped or replaced. Report that uncertainty to
// the user instead of claiming success.
try {
await client?.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS })
onScheduled()
} catch {
onScheduled({ receiptPending: true })
}
} catch (e: any) {
setTxError(e?.shortMessage ?? e?.message ?? 'Unknown error')
setIsSubmitting(false)
Expand Down Expand Up @@ -277,7 +302,15 @@ export function OperationForm({ timelockAddress, chainId, minDelay, onScheduled,
disabled={isSubmitting || !userAddress}
className="flex-1 py-2.5 bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded font-medium"
>
{isPending ? 'Signing…' : isSubmitting ? 'Confirming…' : 'Schedule operation'}
{isPending
? 'Signing…'
: isSubmitting
? safeFlow
? 'Sending to Safe…'
: 'Confirming…'
: safeFlow
? 'Schedule via Safe'
: 'Schedule operation'}
</button>
<button
type="button"
Expand Down
162 changes: 162 additions & 0 deletions src/hooks/useIsSafeWallet.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor } from '@testing-library/react'

const mockUseAccount = vi.fn()
const mockUseConnections = vi.fn()
vi.mock('wagmi', () => ({
useAccount: () => mockUseAccount(),
useConnections: () => mockUseConnections(),
}))

import { useIsSafeWallet } from './useIsSafeWallet'

function liveConnector(opts: {
id: string
name: string
type?: string
uid?: string
provider?: any

Check failure on line 18 in src/hooks/useIsSafeWallet.test.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
getProvider?: () => Promise<any>

Check failure on line 19 in src/hooks/useIsSafeWallet.test.tsx

View workflow job for this annotation

GitHub Actions / Lint, type-check, test, build

Unexpected any. Specify a different type
}) {
return {
id: opts.id,
name: opts.name,
type: opts.type ?? opts.id,
uid: opts.uid ?? 'uid-test',
getProvider:
opts.getProvider ??
(async () => opts.provider ?? { session: { peer: { metadata: {} } } }),
}
}

describe('useIsSafeWallet', () => {
beforeEach(() => {
mockUseAccount.mockReset()
mockUseConnections.mockReset()
mockUseConnections.mockReturnValue([])
})

it('is false when no wallet is connected', () => {
mockUseAccount.mockReturnValue({ connector: undefined })
const { result } = renderHook(() => useIsSafeWallet())
expect(result.current).toBe(false)
})

it('is true synchronously for the Safe Apps SDK connector (id "safe")', () => {
const safe = { id: 'safe', name: 'Safe', type: 'safe', uid: 'uid-safe' }
mockUseAccount.mockReturnValue({ connector: safe })
mockUseConnections.mockReturnValue([{ connector: safe }])
const { result } = renderHook(() => useIsSafeWallet())
expect(result.current).toBe(true)
})

it('stays false for a plain EOA over WalletConnect', async () => {
const conn = liveConnector({
id: 'walletConnect',
name: 'WalletConnect',
uid: 'uid-wc',
provider: { session: { peer: { metadata: { name: 'Rainbow', url: 'https://rainbow.me' } } } },
})
mockUseAccount.mockReturnValue({ connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-wc' } })
mockUseConnections.mockReturnValue([{ connector: conn }])
const { result } = renderHook(() => useIsSafeWallet())
expect(result.current).toBe(false)
await new Promise((r) => setTimeout(r, 10))
expect(result.current).toBe(false)
})

it('flips to true once Safe is detected via WalletConnect peer metadata', async () => {
const conn = liveConnector({
id: 'walletConnect',
name: 'WalletConnect',
uid: 'uid-wc',
provider: {
session: {
peer: { metadata: { name: 'Safe{Wallet}', url: 'https://app.safe.global' } },
},
},
})
mockUseAccount.mockReturnValue({ connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-wc' } })
mockUseConnections.mockReturnValue([{ connector: conn }])
const { result } = renderHook(() => useIsSafeWallet())
expect(result.current).toBe(false)
await waitFor(() => expect(result.current).toBe(true))
})

it('rejects SafePal even over WC (no false positive on look-alikes)', async () => {
const conn = liveConnector({
id: 'walletConnect',
name: 'WalletConnect',
uid: 'uid-wc',
provider: { session: { peer: { metadata: { name: 'SafePal', url: 'https://safepal.com' } } } },
})
mockUseAccount.mockReturnValue({ connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-wc' } })
mockUseConnections.mockReturnValue([{ connector: conn }])
const { result } = renderHook(() => useIsSafeWallet())
await new Promise((r) => setTimeout(r, 10))
expect(result.current).toBe(false)
})

it('picks the connection that matches the active account uid', async () => {
// Two simultaneous connections: an injected EOA and a Safe-over-WC. The
// active account is the WC one — we must read THAT peer metadata, not
// the injected one's.
const injected = liveConnector({
id: 'injected',
name: 'MetaMask',
uid: 'uid-injected',
provider: { session: { peer: { metadata: { name: 'MetaMask' } } } },
})
const safeWc = liveConnector({
id: 'walletConnect',
name: 'WalletConnect',
uid: 'uid-safe-wc',
provider: {
session: {
peer: { metadata: { name: 'Safe{Wallet}', url: 'https://app.safe.global' } },
},
},
})
mockUseAccount.mockReturnValue({
connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-safe-wc' },
})
mockUseConnections.mockReturnValue([{ connector: injected }, { connector: safeWc }])
const { result } = renderHook(() => useIsSafeWallet())
await waitFor(() => expect(result.current).toBe(true))
})

it('does not crash if the live connector lacks getProvider (descriptor only)', async () => {
mockUseAccount.mockReturnValue({
connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-wc' },
})
mockUseConnections.mockReturnValue([
{
connector: {
id: 'walletConnect',
name: 'WalletConnect',
type: 'walletConnect',
uid: 'uid-wc',
},
},
])
const { result } = renderHook(() => useIsSafeWallet())
await new Promise((r) => setTimeout(r, 10))
expect(result.current).toBe(false)
})

it('does not crash if getProvider throws', async () => {
const conn = liveConnector({
id: 'walletConnect',
name: 'WalletConnect',
uid: 'uid-wc',
getProvider: async () => {
throw new Error('provider not ready')
},
})
mockUseAccount.mockReturnValue({ connector: { id: 'walletConnect', name: 'WalletConnect', uid: 'uid-wc' } })
mockUseConnections.mockReturnValue([{ connector: conn }])
const { result } = renderHook(() => useIsSafeWallet())
await new Promise((r) => setTimeout(r, 10))
expect(result.current).toBe(false)
})
})
Loading
Loading