feat(web): enhance proposer functionality with nested safe support (#7028)

* feat(web): enhance proposer functionality with nested safe support

- Updated ProposersList component to use CheckWallet for wallet validation.
- Refactored UpsertProposer to support signing for nested safes, including new utility functions for EIP-1271 signature encoding.
- Added TypeScript dependencies for improved compatibility with Next.js and React.

This update improves the handling of nested safes and enhances the overall proposer management experience.

* feat(web): enhance proposer management with multi-sig support
- Added support for multi-signature delegation in UpsertProposer and DeleteProposerDialog components.
- Integrated PendingDelegationsList to display pending actions for nested safe owners.
- Updated utility functions for EIP-1271 signature encoding to handle multi-owner scenarios.
- Enhanced ProposersList to conditionally show pending delegations based on nested safe ownership.
These changes improve the user experience for managing proposers in nested safe environments.

* fix(workflows): update preview URL format in tx-builder deploy workflow

* feat(web): enhance proposer management and delegation handling
- Updated ProposersList to remove the New chip and conditionally display pending delegations.
- Modified DeleteProposerDialog to allow nested safe owners to delete proposers.
- Enhanced PendingDelegation component to include expiration date and shareable link functionality.
- Implemented accordion layout in PendingDelegationsList for better organization of pending confirmations.
- Updated utility functions to compute TOTP expiration dates.
These changes improve the user experience for managing proposers and pending delegations in nested safe environments.

* fix(web): refine wallet and delegation handling logic
- Updated CheckWallet component to include a check for nested safe owners.
- Changed background color in PendingDelegationCard and PendingDelegationsList for better visual consistency.
- Enhanced TOTP extraction logic in usePendingDelegations hook to ensure valid number conversion.
These changes improve the accuracy of wallet checks and enhance the user interface for pending delegations.

* feat(web): improve edit proposer logic and UI consistency
- Enhanced EditProposerDialog to allow nested safe owners to edit proposers.
- Updated PendingDelegationsList to use a consistent background color for better visual clarity.
These changes enhance the user experience for managing proposer edits and improve the overall interface.

* feat(web): enhance pending delegation handling and UI consistency
- Updated PendingDelegation component to improve shareable link functionality and streamline UI elements.
- Refined PendingDelegationsList to ensure consistent background color across components.
- Enhanced usePendingDelegations hook to implement polling for new signatures and optimize delegation filtering logic.
These changes improve the user experience for managing pending delegations and ensure a more cohesive interface.

* fix(tests): mock useChains hook to return default configs in ProposersList tests

* feat(web): refactor usePendingDelegations hook for improved delegation parsing and filtering
- Introduced parseMessageToDelegation function to streamline the extraction of delegation data from messages.
- Implemented keepLatestPerDelegate and filterActedUponDelegations functions to enhance delegation management logic.
- Optimized the usePendingDelegations hook to improve performance and clarity in handling pending delegations.
These changes enhance the overall efficiency and maintainability of the delegation handling process.

* feat(tests): add comprehensive tests for delegation hooks and services
- Introduced unit tests for useParentSafeThreshold, usePendingDelegations, and useSubmitDelegation hooks to ensure correct functionality and edge case handling.
- Added tests for delegation message creation and confirmation logic to validate proper integration with the messaging system.
- Enhanced test coverage for delegation-related services, improving reliability and maintainability of the codebase.
These additions strengthen the testing framework and ensure robust delegation management in the application.

* refactor(web): update background color in PendingDelegationsList for UI consistency
- Changed background color from a hardcoded value to a CSS variable for improved theming and maintainability.
- Ensured consistent background color across all accordion components within the PendingDelegationsList.
These updates enhance the visual coherence of the user interface for pending delegations.

* fix(md): revert claude claude MD changes

* fix(workflow): update preview URL in tx-builder deployment configuration
- Modified the preview URL in the tx-builder-deploy.yml workflow to include the '/tx-builder/' path for accurate deployment previews.
This change ensures that the deployment preview links direct users to the correct location.

* fix(web): improve delegation handling and error logging
- Updated the PendingDelegation component to include a countdown timer for expiration and enhanced error handling with logging for better debugging.
- Refactored the useSubmitDelegation hook to validate TOTP expiration and improved error management by utilizing the asError utility.
- Adjusted the encodeEIP1271Signature function to return a promise, ensuring consistent handling of asynchronous operations.
These changes enhance the reliability and user experience of delegation management in the application.

* chore(web): remove unused dependency from package.json
- Deleted the "@next/swc-linux-arm64-gnu" dependency from package.json as it is no longer needed. This cleanup helps maintain a leaner and more efficient project structure.

* chore(deps): remove deprecated "@next/swc-linux-arm64-gnu" entry from yarn.lock
- Deleted the "@next/swc-linux-arm64-gnu" entry from yarn.lock as it is no longer required, following the recent cleanup of the package.json. This helps maintain an up-to-date and efficient dependency tree.

* feat(web): enhance delegation polling and error handling in UI components
- Updated constants to include a backoff multiplier for delegation polling.
- Refactored PendingDelegation component to utilize the sameAddress utility for address comparison.
- Introduced DelegationErrorBoundary in PendingDelegationsList to handle loading errors gracefully.
- Improved usePendingDelegations hook with custom polling logic and stable key for dependency comparison.
These changes enhance the reliability and user experience of delegation management in the application.

* refactor(web): enhance delegation components and hooks for improved type safety and functionality
- Updated DelegationErrorBoundary and PendingDelegation components to use function declarations for better readability and type inference.
- Refactored useDelegationPolling and usePendingDelegations hooks to streamline polling logic and improve type definitions.
- Introduced utility functions for delegation origin parsing and filtering, enhancing code clarity and maintainability.
These changes improve the overall structure and reliability of delegation management in the application.

* feat(web): enhance delegation management with edit functionality and improved error handling
- Updated delegation-related types and constants to support 'edit' actions for better delegation management.
- Refactored PendingDelegation and UpsertProposer components to handle edit actions and improve user feedback.
- Streamlined error handling in delegation submission and deletion processes using the asError utility.
- Removed the deprecated useDelegationPolling hook, simplifying the polling logic in usePendingDelegations.
These changes improve the functionality and reliability of delegation operations within the application.

* fix(tests): update delegationMessages tests to include additional properties in origin object
- Enhanced the test cases for delegationMessages by adding 'delegate', 'nestedSafe', and 'label' properties to the origin object for both 'add' and 'remove' actions. This improves the accuracy and coverage of the tests related to delegation management.
This commit is contained in:
Fbartoli
2026-02-18 15:10:37 +01:00
committed by GitHub
parent c4285144b0
commit 835d170ef5
43 changed files with 4938 additions and 76 deletions
+5 -1
View File
@@ -106,4 +106,8 @@ apps/web/src/types/contracts/*
# tx-builder
apps/tx-builder/build/*
apps/tx-builder/build/*
# claude-workflows
todos/*
docs/
@@ -72,7 +72,7 @@ const CheckWallet = ({
return Message.NotSafeOwner
}
if (!allowProposer && isProposer && !isSafeOwner) {
if (!allowProposer && isProposer && !isSafeOwner && !isNestedSafeOwner) {
return Message.NotSafeOwner
}
}, [
@@ -0,0 +1,155 @@
import { render } from '@/tests/test-utils'
import ProposersList from '.'
import { faker } from '@faker-js/faker'
import useProposers from '@/hooks/useProposers'
import { useHasFeature } from '@/hooks/useChains'
import useSafeInfo from '@/hooks/useSafeInfo'
import { extendedSafeInfoBuilder } from '@/tests/builders/safe'
import useWallet from '@/hooks/wallets/useWallet'
import useIsSafeOwner from '@/hooks/useIsSafeOwner'
import { useIsWalletProposer } from '@/hooks/useProposers'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import { useSafeSDK } from '@/hooks/coreSDK/safeCoreSDK'
import type Safe from '@safe-global/protocol-kit'
const mockWalletAddress = faker.finance.ethereumAddress()
jest.mock('@/hooks/wallets/useWallet', () => ({
__esModule: true,
default: jest.fn(() => ({
address: mockWalletAddress,
})),
}))
jest.mock('@/hooks/useIsSafeOwner', () => ({
__esModule: true,
default: jest.fn(() => true),
}))
jest.mock('@/hooks/useProposers', () => ({
__esModule: true,
default: jest.fn(() => ({ data: { results: [] } })),
useIsWalletProposer: jest.fn(() => false),
}))
jest.mock('@/hooks/useChains', () => ({
__esModule: true,
default: jest.fn(() => ({ configs: [] })),
useHasFeature: jest.fn(() => true),
}))
jest.mock('@/hooks/useIsOnlySpendingLimitBeneficiary', () => ({
__esModule: true,
default: jest.fn(() => false),
}))
jest.mock('@/hooks/useIsWrongChain', () => ({
__esModule: true,
default: jest.fn(() => false),
}))
jest.mock('@/hooks/useNestedSafeOwners')
const mockUseNestedSafeOwners = useNestedSafeOwners as jest.MockedFunction<typeof useNestedSafeOwners>
jest.mock('@/hooks/coreSDK/safeCoreSDK')
const mockUseSafeSdk = useSafeSDK as jest.MockedFunction<typeof useSafeSDK>
const mockSafeAddress = faker.finance.ethereumAddress()
jest.mock('@/hooks/useSafeInfo', () => ({
__esModule: true,
default: jest.fn(() => ({
safeAddress: mockSafeAddress,
safe: {
address: { value: mockSafeAddress },
chainId: '1',
owners: [{ value: mockWalletAddress }],
threshold: 1,
deployed: true,
},
safeLoaded: true,
})),
}))
describe('ProposersList', () => {
beforeEach(() => {
jest.clearAllMocks()
mockUseSafeSdk.mockReturnValue({} as unknown as Safe)
mockUseNestedSafeOwners.mockReturnValue([])
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValue(true)
;(useIsWalletProposer as jest.MockedFunction<typeof useIsWalletProposer>).mockReturnValue(false)
;(useHasFeature as jest.MockedFunction<typeof useHasFeature>).mockReturnValue(true)
;(useWallet as jest.MockedFunction<typeof useWallet>).mockReturnValue({
address: mockWalletAddress,
} as ReturnType<typeof useWallet>)
;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValue({
safeAddress: mockSafeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: mockSafeAddress } })
.with({ deployed: true })
.with({ owners: [{ value: mockWalletAddress }] })
.build(),
safeLoaded: true,
} as unknown as ReturnType<typeof useSafeInfo>)
;(useProposers as jest.MockedFunction<typeof useProposers>).mockReturnValue({
data: { results: [] },
} as unknown as ReturnType<typeof useProposers>)
})
it('should enable the Add proposer button for direct Safe owners', () => {
const { getByTestId } = render(<ProposersList />)
const button = getByTestId('add-proposer-btn')
expect(button).not.toBeDisabled()
})
it('should enable the Add proposer button when user is a nested Safe owner', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValue(false)
mockUseNestedSafeOwners.mockReturnValue([faker.finance.ethereumAddress()])
const { getByTestId } = render(<ProposersList />)
const button = getByTestId('add-proposer-btn')
expect(button).not.toBeDisabled()
})
it('should disable the Add proposer button when user is only a proposer', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValue(false)
;(useIsWalletProposer as jest.MockedFunction<typeof useIsWalletProposer>).mockReturnValue(true)
mockUseNestedSafeOwners.mockReturnValue([])
const { getByTestId } = render(<ProposersList />)
const button = getByTestId('add-proposer-btn')
expect(button).toBeDisabled()
})
it('should disable the Add proposer button when user has no relationship to the Safe', () => {
;(useIsSafeOwner as jest.MockedFunction<typeof useIsSafeOwner>).mockReturnValue(false)
;(useIsWalletProposer as jest.MockedFunction<typeof useIsWalletProposer>).mockReturnValue(false)
mockUseNestedSafeOwners.mockReturnValue([])
const { getByTestId, getByLabelText } = render(<ProposersList />)
const button = getByTestId('add-proposer-btn')
expect(button).toBeDisabled()
expect(getByLabelText('Your connected wallet is not a signer of this Safe Account')).toBeInTheDocument()
})
it('should disable the Add proposer button when Safe is undeployed', () => {
;(useSafeInfo as jest.MockedFunction<typeof useSafeInfo>).mockReturnValue({
safeAddress: mockSafeAddress,
safe: extendedSafeInfoBuilder()
.with({ address: { value: mockSafeAddress } })
.with({ deployed: false })
.with({ owners: [{ value: mockWalletAddress }] })
.build(),
safeLoaded: true,
} as unknown as ReturnType<typeof useSafeInfo>)
const { getByTestId } = render(<ProposersList />)
const button = getByTestId('add-proposer-btn')
expect(button).toBeDisabled()
})
})
@@ -1,13 +1,15 @@
import { Chip } from '@/components/common/Chip'
import EnhancedTable from '@/components/common/EnhancedTable'
import tableCss from '@/components/common/EnhancedTable/styles.module.css'
import OnlyOwner from '@/components/common/OnlyOwner'
import CheckWallet from '@/components/common/CheckWallet'
import Track from '@/components/common/Track'
import UpsertProposer from '@/features/proposers/components/UpsertProposer'
import DeleteProposerDialog from '@/features/proposers/components/DeleteProposerDialog'
import EditProposerDialog from '@/features/proposers/components/EditProposerDialog'
import PendingDelegationsList from '@/features/proposers/components/PendingDelegationsList'
import { useParentSafeThreshold } from '@/features/proposers/hooks/useParentSafeThreshold'
import { useHasFeature } from '@/hooks/useChains'
import useProposers from '@/hooks/useProposers'
import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner'
import AddIcon from '@/public/images/common/add.svg'
import { SETTINGS_EVENTS } from '@/services/analytics'
import { Box, Button, Grid, Paper, SvgIcon, Typography } from '@mui/material'
@@ -42,6 +44,9 @@ const ProposersList = () => {
const isEnabled = useHasFeature(FEATURES.PROPOSERS)
const { safe } = useSafeInfo()
const isUndeployedSafe = !safe.deployed
const isNestedSafeOwner = useIsNestedSafeOwner()
const { threshold: parentThreshold } = useParentSafeThreshold()
const showPendingDelegations = isNestedSafeOwner && parentThreshold !== undefined && parentThreshold > 1
const rows = useMemo(() => {
if (!proposers.data) return []
@@ -93,16 +98,18 @@ const ProposersList = () => {
<Grid container spacing={3}>
<Grid item xs>
<Typography fontWeight="bold" mb={2}>
Proposers <Chip label="New" sx={{ backgroundColor: 'secondary.light', color: 'static.main' }} />
Proposers
</Typography>
<Typography mb={2}>
Proposers can suggest transactions but cannot approve or execute them. Signers should review and approve
transactions first. <ExternalLink href={HelpCenterArticle.PROPOSERS}>Learn more</ExternalLink>
</Typography>
{showPendingDelegations && <PendingDelegationsList />}
{isEnabled && (
<Box mb={2}>
<OnlyOwner>
<CheckWallet allowProposer={false}>
{(isOk) => (
<Track {...SETTINGS_EVENTS.PROPOSERS.ADD_PROPOSER}>
<Tooltip title={isUndeployedSafe ? SafeNotActivated : ''}>
@@ -121,7 +128,7 @@ const ProposersList = () => {
</Tooltip>
</Track>
)}
</OnlyOwner>
</CheckWallet>
</Box>
)}
@@ -0,0 +1,58 @@
import type { ReactElement, ReactNode } from 'react'
import { ErrorBoundary } from '@sentry/react'
import { Box, Button, Typography } from '@mui/material'
type FallbackProps = {
error: Error
resetError: () => void
fallbackMessage?: string
onRetry?: () => void
}
type DelegationErrorBoundaryProps = {
children: ReactNode
fallbackMessage?: string
onRetry?: () => void
}
function DelegationFallback({ error, resetError, fallbackMessage, onRetry }: FallbackProps): ReactElement {
function handleRetry(): void {
onRetry?.()
resetError()
}
return (
<Box
sx={{
p: 2,
bgcolor: 'var(--color-error-background)',
borderRadius: 1,
border: '1px solid var(--color-error-main)',
}}
>
<Typography variant="body2" color="error.main" gutterBottom>
{fallbackMessage || 'Something went wrong loading this content.'}
</Typography>
{process.env.NODE_ENV !== 'production' && (
<Typography variant="caption" color="text.secondary" component="pre" sx={{ mb: 1, whiteSpace: 'pre-wrap' }}>
{error.message}
</Typography>
)}
<Button size="small" variant="outlined" color="error" onClick={handleRetry}>
Try again
</Button>
</Box>
)
}
function DelegationErrorBoundary({ children, fallbackMessage, onRetry }: DelegationErrorBoundaryProps): ReactElement {
return (
<ErrorBoundary
fallback={(props) => <DelegationFallback {...props} fallbackMessage={fallbackMessage} onRetry={onRetry} />}
>
{children}
</ErrorBoundary>
)
}
export default DelegationErrorBoundary
@@ -1,12 +1,20 @@
import CheckWallet from '@/components/common/CheckWallet'
import Track from '@/components/common/Track'
import { signProposerData, signProposerTypedData } from '@/features/proposers/utils/utils'
import {
encodeEIP1271Signature,
signProposerData,
signProposerTypedData,
signProposerTypedDataForSafe,
} from '@/features/proposers/utils/utils'
import { useParentSafeThreshold } from '@/features/proposers/hooks/useParentSafeThreshold'
import { buildDelegationOrigin, createDelegationMessage } from '@/features/proposers/services/delegationMessages'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
import useWallet from '@/hooks/wallets/useWallet'
import DeleteIcon from '@/public/images/common/delete.svg'
import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
import { useAppDispatch } from '@/store'
import { showNotification } from '@/store/notificationsSlice'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import { shortenAddress } from '@safe-global/utils/utils/formatters'
import { isEthSignWallet } from '@/utils/wallets'
import {
@@ -14,8 +22,10 @@ import {
useDelegatesDeleteDelegateV2Mutation,
type Delegate,
} from '@safe-global/store/gateway/AUTO_GENERATED/delegates'
import { getDelegateTypedData } from '@safe-global/utils/services/delegates'
import React, { useState } from 'react'
import {
Alert,
Dialog,
DialogTitle,
Typography,
@@ -35,6 +45,9 @@ import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
type DeleteProposerProps = {
wallet: ReturnType<typeof useWallet>
@@ -47,9 +60,15 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
const [open, setOpen] = useState<boolean>(false)
const [error, setError] = useState<Error>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [multiSigInitiated, setMultiSigInitiated] = useState<boolean>(false)
const [deleteDelegateV1] = useDelegatesDeleteDelegateV1Mutation()
const [deleteDelegateV2] = useDelegatesDeleteDelegateV2Mutation()
const dispatch = useAppDispatch()
const isNestedSafeOwner = useIsNestedSafeOwner()
const nestedSafeOwners = useNestedSafeOwners()
const { threshold: parentThreshold, owners: parentOwners } = useParentSafeThreshold()
const isMultiSigRequired = isNestedSafeOwner && parentThreshold !== undefined && parentThreshold > 1
const onConfirm = async () => {
setError(undefined)
@@ -64,30 +83,64 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
try {
const shouldEthSign = isEthSignWallet(wallet)
const signer = await getAssertedChainSigner(wallet.provider)
const signature = shouldEthSign
? await signProposerData(proposer.delegate, signer)
: await signProposerTypedData(chainId, proposer.delegate, signer)
const parentSafeAddress = isNestedSafeOwner && nestedSafeOwners ? nestedSafeOwners[0] : undefined
if (parentSafeAddress && isMultiSigRequired) {
// Multi-sig flow: create off-chain message on parent Safe for signature collection
const eoaSignature = await signProposerTypedDataForSafe(chainId, proposer.delegate, parentSafeAddress, signer)
const delegateTypedData = getDelegateTypedData(chainId, proposer.delegate) as TypedData
const origin = buildDelegationOrigin('remove', proposer.delegate, safeAddress, proposer.label)
await createDelegationMessage(dispatch, chainId, parentSafeAddress, delegateTypedData, eoaSignature, origin)
setMultiSigInitiated(true)
trackEvent(SETTINGS_EVENTS.PROPOSERS.SUBMIT_REMOVE_PROPOSER)
setIsLoading(false)
return
}
let signature: string
if (parentSafeAddress) {
// Single-sig nested Safe owner
const eoaSignature = await signProposerTypedDataForSafe(chainId, proposer.delegate, parentSafeAddress, signer)
signature = await encodeEIP1271Signature(parentSafeAddress, eoaSignature)
if (shouldEthSign) {
await deleteDelegateV1({
chainId,
delegateAddress: proposer.delegate,
deleteDelegateDto: {
delegate: proposer.delegate,
delegator: proposer.delegator,
signature,
},
}).unwrap()
} else {
await deleteDelegateV2({
chainId,
delegateAddress: proposer.delegate,
deleteDelegateV2Dto: {
delegator: proposer.delegator,
delegator: parentSafeAddress,
safe: safeAddress,
signature,
},
}).unwrap()
} else {
signature = shouldEthSign
? await signProposerData(proposer.delegate, signer)
: await signProposerTypedData(chainId, proposer.delegate, signer)
if (shouldEthSign) {
await deleteDelegateV1({
chainId,
delegateAddress: proposer.delegate,
deleteDelegateDto: {
delegate: proposer.delegate,
delegator: proposer.delegator,
signature,
},
}).unwrap()
} else {
await deleteDelegateV2({
chainId,
delegateAddress: proposer.delegate,
deleteDelegateV2Dto: {
delegator: proposer.delegator,
safe: safeAddress,
signature,
},
}).unwrap()
}
}
trackEvent(SETTINGS_EVENTS.PROPOSERS.SUBMIT_REMOVE_PROPOSER)
@@ -101,8 +154,8 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
}),
)
setOpen(false)
} catch (error) {
setError(error as Error)
} catch (err) {
setError(asError(err))
return
} finally {
setIsLoading(false)
@@ -114,9 +167,13 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
setOpen(false)
setIsLoading(false)
setError(undefined)
setMultiSigInitiated(false)
}
const canDelete = wallet?.address === proposer.delegate || wallet?.address === proposer.delegator
const canDelete =
wallet?.address === proposer.delegate ||
wallet?.address === proposer.delegator ||
(isNestedSafeOwner && nestedSafeOwners?.includes(proposer.delegator))
return (
<>
@@ -152,7 +209,7 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
<DialogTitle>
<Box display="flex" alignItems="center">
<Typography variant="h6" fontWeight={700} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
Delete this proposer?
{multiSigInitiated ? 'Signature collection initiated' : 'Delete this proposer?'}
</Typography>
<Box flexGrow={1} />
@@ -166,49 +223,84 @@ const InternalDeleteProposer = ({ wallet, safeAddress, chainId, proposer }: Dele
<Divider />
<DialogContent>
<Box mb={2}>
<Typography>
Deleting this proposer will permanently remove the address, and it won&apos;t be able to suggest
transactions anymore.
<br />
<br />
To complete this action, confirm it with your connected wallet signature.
</Typography>
</Box>
{multiSigInitiated ? (
<>
<Alert severity="success" sx={{ mb: 2 }}>
1 of {parentThreshold} signatures collected
</Alert>
{error && (
<Box mt={2}>
<ErrorMessage error={error}>Error deleting proposer</ErrorMessage>
</Box>
<Typography variant="body2" mb={2}>
The removal request has been created as an off-chain message on your parent Safe. Other owners of the
parent Safe need to sign it before the proposer can be removed.
</Typography>
<Typography variant="body2" color="text.secondary">
The other parent Safe owners can find and sign this pending delegation on the proposer settings page of
this Safe.
</Typography>
</>
) : (
<>
{isMultiSigRequired && (
<Alert severity="info" sx={{ mb: 2 }}>
This requires {parentThreshold} of {parentOwners?.length ?? '?'} parent Safe owner signatures to
complete.
</Alert>
)}
<Box mb={2}>
<Typography>
Deleting this proposer will permanently remove the address, and it won&apos;t be able to suggest
transactions anymore.
<br />
<br />
To complete this action, confirm it with your connected wallet signature.
</Typography>
</Box>
{error && (
<Box mt={2}>
<ErrorMessage error={error}>Error deleting proposer</ErrorMessage>
</Box>
)}
<NetworkWarning action="sign" />
</>
)}
<NetworkWarning action="sign" />
</DialogContent>
<Divider />
<DialogActions sx={{ padding: 3, justifyContent: 'space-between' }}>
<Button data-testid="reject-delete-proposer-btn" size="small" variant="text" onClick={onCancel}>
No, keep it
</Button>
<CheckWallet checkNetwork={!isLoading}>
{(isOk) => (
<Button
data-testid="confirm-delete-proposer-btn"
size="small"
variant="danger"
onClick={onConfirm}
disabled={!isOk || isLoading || !canDelete}
sx={{
minWidth: '122px',
minHeight: '36px',
}}
>
{isLoading ? <CircularProgress size={20} /> : 'Yes, delete'}
{multiSigInitiated ? (
<Button variant="contained" onClick={onCancel}>
Done
</Button>
) : (
<>
<Button data-testid="reject-delete-proposer-btn" size="small" variant="text" onClick={onCancel}>
No, keep it
</Button>
)}
</CheckWallet>
<CheckWallet checkNetwork={!isLoading}>
{(isOk) => (
<Button
data-testid="confirm-delete-proposer-btn"
size="small"
variant="danger"
onClick={onConfirm}
disabled={!isOk || isLoading || !canDelete}
sx={{
minWidth: '122px',
minHeight: '36px',
}}
>
{isLoading ? <CircularProgress size={20} /> : 'Yes, delete'}
</Button>
)}
</CheckWallet>
</>
)}
</DialogActions>
</Dialog>
</>
@@ -2,6 +2,8 @@ import CheckWallet from '@/components/common/CheckWallet'
import Track from '@/components/common/Track'
import UpsertProposer from '@/features/proposers/components/UpsertProposer'
import useWallet from '@/hooks/wallets/useWallet'
import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import EditIcon from '@/public/images/common/edit.svg'
import { SETTINGS_EVENTS } from '@/services/analytics'
import { IconButton, SvgIcon, Tooltip } from '@mui/material'
@@ -11,8 +13,11 @@ import React, { useState } from 'react'
const EditProposerDialog = ({ proposer }: { proposer: Delegate }) => {
const [open, setOpen] = useState<boolean>(false)
const wallet = useWallet()
const isNestedSafeOwner = useIsNestedSafeOwner()
const nestedSafeOwners = useNestedSafeOwners()
const canEdit = wallet?.address === proposer.delegator
const canEdit =
wallet?.address === proposer.delegator || (isNestedSafeOwner && nestedSafeOwners?.includes(proposer.delegator))
return (
<>
@@ -0,0 +1,215 @@
import type { ReactElement } from 'react'
import { useState } from 'react'
import { Box, Button, CircularProgress, Typography } from '@mui/material'
import { Countdown } from '@/components/common/Countdown'
import EthHashInfo from '@/components/common/EthHashInfo'
import ErrorMessage from '@/components/tx/ErrorMessage'
import CopyTooltip from '@/components/common/CopyTooltip'
import { sameAddress } from '@safe-global/utils/utils/addresses'
import { signProposerTypedDataForSafe } from '@/features/proposers/utils/utils'
import { confirmDelegationMessage } from '@/features/proposers/services/delegationMessages'
import { useSubmitDelegation } from '@/features/proposers/hooks/useSubmitDelegation'
import { getTotpExpirationDate } from '@/features/proposers/utils/totp'
import useChainId from '@/hooks/useChainId'
import { useCurrentChain } from '@/hooks/useChains'
import useWallet from '@/hooks/wallets/useWallet'
import useOrigin from '@/hooks/useOrigin'
import { useAppDispatch } from '@/store'
import { showNotification } from '@/store/notificationsSlice'
import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk'
import { AppRoutes } from '@/config/routes'
import { logError } from '@/services/exceptions'
import ErrorCodes from '@safe-global/utils/services/exceptions/ErrorCodes'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import type { PendingDelegation as PendingDelegationType } from '@/features/proposers/types'
type PendingDelegationProps = {
delegation: PendingDelegationType
onRefetch: () => void
}
function PendingDelegation({ delegation, onRefetch }: PendingDelegationProps): ReactElement {
const [isSignLoading, setIsSignLoading] = useState(false)
const [error, setError] = useState<Error>()
const chainId = useChainId()
const chain = useCurrentChain()
const wallet = useWallet()
const dispatch = useAppDispatch()
const origin = useOrigin()
const { submitDelegation, isSubmitting } = useSubmitDelegation()
const hasAlreadySigned = delegation.confirmations.some((c) => sameAddress(c.owner.value, wallet?.address))
const expirationDate = getTotpExpirationDate(delegation.totp)
const remainingSeconds = Math.max(0, Math.floor((expirationDate.getTime() - Date.now()) / 1000))
// Link to the parent safe's message page where other owners can sign
const parentSafeId = chain?.shortName
? `${chain.shortName}:${delegation.parentSafeAddress}`
: `${chainId}:${delegation.parentSafeAddress}`
const shareUrl = origin
? `${origin}${AppRoutes.transactions.msg}?safe=${parentSafeId}&messageHash=${delegation.messageHash}`
: ''
const handleSign = async () => {
if (!wallet?.provider) return
setError(undefined)
setIsSignLoading(true)
try {
const signer = await getAssertedChainSigner(wallet.provider)
const eoaSignature = await signProposerTypedDataForSafe(
chainId,
delegation.delegateAddress,
delegation.parentSafeAddress,
signer,
)
await confirmDelegationMessage(dispatch, chainId, delegation.messageHash, eoaSignature)
const newConfirmationsCount = delegation.confirmationsSubmitted + 1
if (newConfirmationsCount >= delegation.confirmationsRequired) {
onRefetch()
dispatch(
showNotification({
variant: 'success',
groupKey: 'delegation-threshold-met',
title: 'Threshold met!',
message: 'All required signatures have been collected. You can now submit the delegation.',
}),
)
} else {
dispatch(
showNotification({
variant: 'success',
groupKey: 'delegation-signed',
title: 'Signature added',
message: `${newConfirmationsCount} of ${delegation.confirmationsRequired} signatures collected.`,
}),
)
onRefetch()
}
} catch (err) {
const error = asError(err)
setError(error)
logError(ErrorCodes._820, err)
} finally {
setIsSignLoading(false)
}
}
const handleSubmit = async () => {
setError(undefined)
try {
await submitDelegation(delegation)
dispatch(
showNotification({
variant: 'success',
groupKey: 'delegation-submitted',
title: `Proposer ${delegation.action === 'add' ? 'added' : delegation.action === 'edit' ? 'updated' : 'removed'} successfully!`,
message: '',
}),
)
onRefetch()
} catch (err) {
const error = asError(err)
setError(error)
logError(ErrorCodes._820, err)
}
}
function renderActionButton(): ReactElement | null {
if (delegation.status === 'ready') {
return (
<Button
size="small"
variant="contained"
onClick={handleSubmit}
disabled={isSubmitting}
sx={{ minWidth: '140px' }}
>
{isSubmitting ? <CircularProgress size={16} /> : 'Submit delegation'}
</Button>
)
}
if (delegation.status !== 'pending') {
return null
}
if (hasAlreadySigned) {
return (
<CopyTooltip text={shareUrl} initialToolTipText="Copy link to share">
<Button size="small" variant="outlined" sx={{ minWidth: '100px' }} disabled={!shareUrl}>
Copy link
</Button>
</CopyTooltip>
)
}
return (
<Button size="small" variant="contained" onClick={handleSign} disabled={isSignLoading} sx={{ minWidth: '80px' }}>
{isSignLoading ? <CircularProgress size={16} /> : 'Sign'}
</Button>
)
}
return (
<Box>
<Box sx={{ bgcolor: 'var(--color-border-background)', borderRadius: 1, p: 2 }}>
<Box display="flex" alignItems="center" gap={3}>
<Typography variant="body2" sx={{ whiteSpace: 'nowrap' }}>
{delegation.action === 'remove'
? 'Remove proposer:'
: delegation.action === 'edit'
? 'Edit proposer:'
: 'New proposer:'}
</Typography>
<Box sx={{ '& .ethHashInfo-name': { fontWeight: 700 } }}>
<EthHashInfo
address={delegation.delegateAddress}
showCopyButton
shortAddress={false}
name={delegation.delegateLabel}
hasExplorer
/>
</Box>
</Box>
</Box>
<Typography variant="caption" color="text.secondary" display="block" mt={1}>
{remainingSeconds > 0 ? (
<>
Expires in <Countdown seconds={remainingSeconds} />
</>
) : (
<Typography component="span" variant="caption" color="error">
Expired
</Typography>
)}
</Typography>
<Box display="flex" alignItems="center" justifyContent="space-between" mt={2}>
<Typography variant="body1">
<Box component="span" fontWeight={700}>
{delegation.confirmationsSubmitted}/{delegation.confirmationsRequired}
</Box>{' '}
signatures collected
</Typography>
{renderActionButton()}
</Box>
{error && (
<Box mt={1}>
<ErrorMessage error={error}>
{delegation.status === 'ready' ? 'Error submitting delegation' : 'Error signing delegation'}
</ErrorMessage>
</Box>
)}
</Box>
)
}
export default PendingDelegation
@@ -0,0 +1,72 @@
import type { ReactElement } from 'react'
import { Accordion, AccordionDetails, AccordionSummary, Box, Chip, Divider, Typography } from '@mui/material'
import ExpandMoreIcon from '@mui/icons-material/ExpandMore'
import PendingDelegation from './PendingDelegation'
import DelegationErrorBoundary from './DelegationErrorBoundary'
import { usePendingDelegations } from '@/features/proposers/hooks/usePendingDelegations'
function PendingDelegationsList(): ReactElement | null {
const { pendingDelegations, isLoading, refetch } = usePendingDelegations()
if (isLoading || pendingDelegations.length === 0) return null
return (
<Box mb={2}>
<DelegationErrorBoundary fallbackMessage="Failed to load pending delegations." onRetry={refetch}>
<Accordion
defaultExpanded
disableGutters
elevation={0}
sx={{
'&.MuiAccordion-root': {
border: '1px solid var(--color-border-light)',
borderRadius: '6px',
backgroundColor: 'var(--color-background-paper) !important',
},
'& .MuiAccordionSummary-root': {
backgroundColor: 'var(--color-background-paper) !important',
},
'& .MuiAccordionDetails-root': {
backgroundColor: 'var(--color-background-paper) !important',
},
}}
>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Box display="flex" alignItems="center" gap={1}>
<Typography variant="subtitle2" fontWeight={700}>
Pending confirmations
</Typography>
<Chip
label={pendingDelegations.length > 19 ? '19+' : pendingDelegations.length}
size="small"
sx={{
bgcolor: 'warning.light',
color: 'text.dark',
fontWeight: 700,
fontSize: '11px',
letterSpacing: '1px',
height: '20px',
'& .MuiChip-label': {
px: 0.5,
},
}}
/>
</Box>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, px: 2 }}>
{pendingDelegations.map((delegation, index) => (
<Box key={delegation.messageHash}>
<DelegationErrorBoundary fallbackMessage="Failed to load this delegation.">
<PendingDelegation delegation={delegation} onRefetch={refetch} />
</DelegationErrorBoundary>
{index < pendingDelegations.length - 1 && <Divider sx={{ my: 2 }} />}
</Box>
))}
</AccordionDetails>
</Accordion>
</DelegationErrorBoundary>
</Box>
)
}
export default PendingDelegationsList
@@ -0,0 +1,28 @@
import * as proposerUtils from '@/features/proposers/utils/utils'
describe('UpsertProposer signing logic', () => {
beforeEach(() => {
jest.clearAllMocks()
})
describe('signProposerTypedDataForSafe', () => {
it('should be exported and callable', () => {
expect(proposerUtils.signProposerTypedDataForSafe).toBeDefined()
expect(typeof proposerUtils.signProposerTypedDataForSafe).toBe('function')
})
})
describe('encodeEIP1271Signature', () => {
it('should be exported and callable', () => {
expect(proposerUtils.encodeEIP1271Signature).toBeDefined()
expect(typeof proposerUtils.encodeEIP1271Signature).toBe('function')
})
})
describe('signProposerTypedData', () => {
it('should be exported and callable', () => {
expect(proposerUtils.signProposerTypedData).toBeDefined()
expect(typeof proposerUtils.signProposerTypedData).toBe('function')
})
})
})
@@ -4,7 +4,14 @@ import EthHashInfo from '@/components/common/EthHashInfo'
import NameInput from '@/components/common/NameInput'
import NetworkWarning from '@/components/new-safe/create/NetworkWarning'
import ErrorMessage from '@/components/tx/ErrorMessage'
import { signProposerData, signProposerTypedData } from '@/features/proposers/utils/utils'
import {
encodeEIP1271Signature,
signProposerData,
signProposerTypedData,
signProposerTypedDataForSafe,
} from '@/features/proposers/utils/utils'
import { useParentSafeThreshold } from '@/features/proposers/hooks/useParentSafeThreshold'
import { buildDelegationOrigin, createDelegationMessage } from '@/features/proposers/services/delegationMessages'
import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import useWallet from '@/hooks/wallets/useWallet'
@@ -12,6 +19,7 @@ import { SETTINGS_EVENTS, trackEvent } from '@/services/analytics'
import { getAssertedChainSigner } from '@/services/tx/tx-sender/sdk'
import { useAppDispatch } from '@/store'
import { showNotification } from '@/store/notificationsSlice'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import { shortenAddress } from '@safe-global/utils/utils/formatters'
import { addressIsNotCurrentSafe, addressIsNotOwner } from '@safe-global/utils/utils/validation'
import { isEthSignWallet } from '@/utils/wallets'
@@ -35,9 +43,13 @@ import {
type CreateDelegateDto,
type Delegate,
} from '@safe-global/store/gateway/AUTO_GENERATED/delegates'
import { getDelegateTypedData } from '@safe-global/utils/services/delegates'
import { type BaseSyntheticEvent, useCallback, useMemo, useState } from 'react'
import { FormProvider, useForm, type Validate } from 'react-hook-form'
import useSafeInfo from '@/hooks/useSafeInfo'
import { useIsNestedSafeOwner } from '@/hooks/useIsNestedSafeOwner'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import type { TypedData } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
type UpsertProposerProps = {
onClose: () => void
@@ -58,6 +70,7 @@ type ProposerEntry = {
const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) => {
const [error, setError] = useState<Error>()
const [isLoading, setIsLoading] = useState<boolean>(false)
const [multiSigInitiated, setMultiSigInitiated] = useState<boolean>(false)
const [addDelegateV1] = useDelegatesPostDelegateV1Mutation()
const [addDelegateV2] = useDelegatesPostDelegateV2Mutation()
const dispatch = useAppDispatch()
@@ -66,6 +79,9 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
const wallet = useWallet()
const safeAddress = useSafeAddress()
const { safe } = useSafeInfo()
const isNestedSafeOwner = useIsNestedSafeOwner()
const nestedSafeOwners = useNestedSafeOwners()
const { threshold: parentThreshold, owners: parentOwners } = useParentSafeThreshold()
const methods = useForm<ProposerEntry>({
defaultValues: {
@@ -86,6 +102,8 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
const { handleSubmit, formState } = methods
const isMultiSigRequired = isNestedSafeOwner && parentThreshold !== undefined && parentThreshold > 1
const onConfirm = handleSubmit(async (data: ProposerEntry) => {
if (!wallet) return
@@ -95,19 +113,48 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
try {
const shouldEthSign = isEthSignWallet(wallet)
const signer = await getAssertedChainSigner(wallet.provider)
const signature = shouldEthSign
? await signProposerData(data.address, signer)
: await signProposerTypedData(chainId, data.address, signer)
const parentSafeAddress = isNestedSafeOwner && nestedSafeOwners ? nestedSafeOwners[0] : undefined
let signature: string
let delegator: string
if (parentSafeAddress) {
if (isMultiSigRequired) {
// Multi-sig flow: create off-chain message on parent Safe for signature collection
const eoaSignature = await signProposerTypedDataForSafe(chainId, data.address, parentSafeAddress, signer)
const delegateTypedData = getDelegateTypedData(chainId, data.address) as TypedData
const origin = buildDelegationOrigin(proposer ? 'edit' : 'add', data.address, safeAddress, data.name)
await createDelegationMessage(dispatch, chainId, parentSafeAddress, delegateTypedData, eoaSignature, origin)
setMultiSigInitiated(true)
trackEvent(SETTINGS_EVENTS.PROPOSERS.SUBMIT_ADD_PROPOSER)
setIsLoading(false)
return
}
// Single-sig nested Safe owner: sign and submit immediately
const eoaSignature = await signProposerTypedDataForSafe(chainId, data.address, parentSafeAddress, signer)
signature = await encodeEIP1271Signature(parentSafeAddress, eoaSignature)
delegator = parentSafeAddress
} else {
// Direct owner: sign delegate typed data directly
const eoaSignature = shouldEthSign
? await signProposerData(data.address, signer)
: await signProposerTypedData(chainId, data.address, signer)
signature = eoaSignature
delegator = wallet.address
}
const createDelegateDto: CreateDelegateDto = {
delegate: data.address,
delegator: wallet.address,
delegator,
label: data.name,
signature,
safe: safeAddress,
}
if (shouldEthSign) {
if (shouldEthSign && !parentSafeAddress) {
await addDelegateV1({ chainId, createDelegateDto }).unwrap()
} else {
await addDelegateV2({ chainId, createDelegateDto }).unwrap()
@@ -127,8 +174,8 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
)
onSuccess()
} catch (error) {
setError(error as Error)
} catch (err) {
setError(asError(err))
return
} finally {
setIsLoading(false)
@@ -148,7 +195,53 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
}
const isEditing = !!proposer
const canEdit = wallet?.address === proposer?.delegator
const canEdit =
wallet?.address === proposer?.delegator ||
(isNestedSafeOwner && nestedSafeOwners?.includes(proposer?.delegator ?? ''))
if (multiSigInitiated) {
return (
<Dialog open onClose={onClose}>
<DialogTitle>
<Box display="flex" alignItems="center">
<Typography variant="h6" fontWeight={700}>
Signature collection initiated
</Typography>
<Box flexGrow={1} />
<IconButton aria-label="close" onClick={onClose}>
<Close />
</IconButton>
</Box>
</DialogTitle>
<Divider />
<DialogContent>
<Alert severity="success" sx={{ mb: 2 }}>
1 of {parentThreshold} signatures collected
</Alert>
<Typography variant="body2" mb={2}>
The delegation request has been created as an off-chain message on your parent Safe. Other owners of the
parent Safe need to sign it before the proposer can be added.
</Typography>
<Typography variant="body2" color="text.secondary">
The other parent Safe owners can find and sign this pending delegation on the proposer settings page of this
Safe.
</Typography>
</DialogContent>
<Divider />
<DialogActions sx={{ padding: 3 }}>
<Button variant="contained" onClick={onClose}>
Done
</Button>
</DialogActions>
</Dialog>
)
}
return (
<Dialog open onClose={onCancel}>
@@ -171,6 +264,13 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
<Divider />
<DialogContent>
{isMultiSigRequired && (
<Alert severity="info" sx={{ mb: 2 }}>
This requires {parentThreshold} of {parentOwners?.length ?? '?'} parent Safe owner signatures to
complete.
</Alert>
)}
<Box mb={2}>
<Typography variant="body2">
You&apos;re about to grant this address the ability to propose transactions. To complete the setup,
@@ -178,7 +278,7 @@ const UpsertProposer = ({ onClose, onSuccess, proposer }: UpsertProposerProps) =
</Typography>
</Box>
<Alert severity="info">Proposers name and address are publicly visible.</Alert>
<Alert severity="info">Proposer&apos;s name and address are publicly visible.</Alert>
<Box my={2}>
{isEditing ? (
@@ -0,0 +1,5 @@
/** TOTP interval in seconds (1 hour) */
export const TOTP_INTERVAL_SECONDS = 3600
/** Polling interval for pending delegations (milliseconds) */
export const DELEGATION_POLLING_INTERVAL_MS = 5000
@@ -0,0 +1,152 @@
import { renderHook } from '@/tests/test-utils'
import { useParentSafeThreshold } from '../useParentSafeThreshold'
import * as useNestedSafeOwnersModule from '@/hooks/useNestedSafeOwners'
import * as useChainIdModule from '@/hooks/useChainId'
import * as safesQueries from '@safe-global/store/gateway/AUTO_GENERATED/safes'
import { faker } from '@faker-js/faker'
import { checksumAddress } from '@safe-global/utils/utils/addresses'
describe('useParentSafeThreshold', () => {
const chainId = '1'
const parentSafeAddress = checksumAddress(faker.finance.ethereumAddress())
const owners = [
{ value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
{ value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
{ value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
]
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(useChainIdModule, 'default').mockReturnValue(chainId)
})
it('should return undefined values when no nested safe owners exist', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue(null)
jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: undefined,
isLoading: false,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
const { result } = renderHook(() => useParentSafeThreshold())
expect(result.current).toEqual({
threshold: undefined,
owners: undefined,
parentSafeAddress: undefined,
isLoading: false,
})
})
it('should return undefined values when nested safe owners is empty', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([])
jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: undefined,
isLoading: false,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
const { result } = renderHook(() => useParentSafeThreshold())
expect(result.current).toEqual({
threshold: undefined,
owners: undefined,
parentSafeAddress: undefined,
isLoading: false,
})
})
it('should return isLoading=true while loading', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: undefined,
isLoading: true,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
const { result } = renderHook(() => useParentSafeThreshold())
expect(result.current).toEqual({
threshold: undefined,
owners: undefined,
parentSafeAddress: undefined,
isLoading: true,
})
})
it('should return threshold and owners when parent safe data is available', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: {
threshold: 2,
owners,
address: { value: parentSafeAddress, name: null, logoUri: null },
chainId,
nonce: 0,
implementationVersionState: 'UP_TO_DATE',
modules: [],
guard: null,
fallbackHandler: null,
version: '1.4.1',
collectiblesTag: '0',
txQueuedTag: '0',
txHistoryTag: '0',
messagesTag: '0',
},
isLoading: false,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
const { result } = renderHook(() => useParentSafeThreshold())
expect(result.current).toEqual({
threshold: 2,
owners,
parentSafeAddress,
isLoading: false,
})
})
it('should use first owner as parent safe address', () => {
const secondParentSafe = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress, secondParentSafe])
const mockQuery = jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: {
threshold: 1,
owners: owners.slice(0, 1),
address: { value: parentSafeAddress, name: null, logoUri: null },
chainId,
nonce: 0,
implementationVersionState: 'UP_TO_DATE',
modules: [],
guard: null,
fallbackHandler: null,
version: '1.4.1',
collectiblesTag: '0',
txQueuedTag: '0',
txHistoryTag: '0',
messagesTag: '0',
},
isLoading: false,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
const { result } = renderHook(() => useParentSafeThreshold())
expect(result.current.parentSafeAddress).toBe(parentSafeAddress)
expect(mockQuery).toHaveBeenCalledWith({ chainId, safeAddress: parentSafeAddress }, { skip: false })
})
it('should skip query when no parent safe address', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue(null)
const mockQuery = jest.spyOn(safesQueries, 'useSafesGetSafeV1Query').mockReturnValue({
data: undefined,
isLoading: false,
refetch: jest.fn(),
} as ReturnType<typeof safesQueries.useSafesGetSafeV1Query>)
renderHook(() => useParentSafeThreshold())
expect(mockQuery).toHaveBeenCalledWith({ chainId, safeAddress: '' }, { skip: true })
})
})
@@ -0,0 +1,471 @@
import { renderHook } from '@/tests/test-utils'
import { usePendingDelegations } from '../usePendingDelegations'
import * as useChainIdModule from '@/hooks/useChainId'
import * as useSafeAddressModule from '@/hooks/useSafeAddress'
import * as useNestedSafeOwnersModule from '@/hooks/useNestedSafeOwners'
import * as useProposersModule from '@/hooks/useProposers'
import * as messagesQueries from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import type { MessageItem, DateLabel } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import * as totpModule from '@/features/proposers/utils/totp'
import { faker } from '@faker-js/faker'
import { checksumAddress } from '@safe-global/utils/utils/addresses'
describe('usePendingDelegations', () => {
const chainId = '1'
const safeAddress = checksumAddress(faker.finance.ethereumAddress())
const parentSafeAddress = checksumAddress(faker.finance.ethereumAddress())
const delegateAddress = checksumAddress(faker.finance.ethereumAddress())
const currentTotp = Math.floor(Date.now() / 1000 / 3600)
const createMessageItem = (
overrides: Partial<{
action: 'add' | 'remove' | 'edit'
delegate: string
nestedSafe: string
label: string
totp: number
confirmationsSubmitted: number
confirmationsRequired: number
messageHash: string
creationTimestamp: number
}> = {},
): MessageItem => {
const action = overrides.action ?? 'add'
const delegate = overrides.delegate ?? delegateAddress
const nestedSafe = overrides.nestedSafe ?? safeAddress
const label = overrides.label ?? 'Test Proposer'
const totp = overrides.totp ?? currentTotp
return {
type: 'MESSAGE',
messageHash: overrides.messageHash ?? `0x${faker.string.hexadecimal({ length: 64 })}`,
status: 'NEEDS_CONFIRMATION',
logoUri: null,
name: null,
message: {
domain: { chainId: Number(chainId) },
types: { Delegate: [{ name: 'delegateAddress', type: 'address' }] },
primaryType: 'Delegate',
message: { delegateAddress: delegate, totp },
},
creationTimestamp: overrides.creationTimestamp ?? Date.now(),
modifiedTimestamp: Date.now(),
confirmationsSubmitted: overrides.confirmationsSubmitted ?? 1,
confirmationsRequired: overrides.confirmationsRequired ?? 2,
proposedBy: { value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
confirmations: [
{
owner: { value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
signature: `0x${faker.string.hexadecimal({ length: 130 })}`,
},
],
preparedSignature: null,
origin: JSON.stringify({
type: 'proposer-delegation',
action,
delegate,
nestedSafe,
label,
}),
}
}
const createDateLabel = (): DateLabel => ({
type: 'DATE_LABEL',
timestamp: Date.now(),
})
const mockRefetch = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(useChainIdModule, 'default').mockReturnValue(chainId)
jest.spyOn(useSafeAddressModule, 'default').mockReturnValue(safeAddress)
jest.spyOn(totpModule, 'isTotpValid').mockReturnValue(true)
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
})
it('should return empty array when no parent safe address', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue(null)
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: undefined,
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toEqual([])
expect(result.current.isLoading).toBe(false)
})
it('should return empty array when no messages', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toEqual([])
})
it('should filter out non-MESSAGE type items', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const messageItem = createMessageItem()
const dateLabel = createDateLabel()
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [dateLabel, messageItem] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].messageHash).toBe(messageItem.messageHash)
})
it('should filter out messages for different nested safes', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const otherNestedSafe = checksumAddress(faker.finance.ethereumAddress())
const messageForOtherSafe = createMessageItem({ nestedSafe: otherNestedSafe })
const messageForCurrentSafe = createMessageItem()
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [messageForOtherSafe, messageForCurrentSafe] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].messageHash).toBe(messageForCurrentSafe.messageHash)
})
it('should filter out expired delegations (invalid TOTP)', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const expiredMessage = createMessageItem({ totp: currentTotp - 10 })
const validMessage = createMessageItem()
jest.spyOn(totpModule, 'isTotpValid').mockImplementation((totp) => totp === currentTotp)
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [expiredMessage, validMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].messageHash).toBe(validMessage.messageHash)
})
it('should correctly parse delegation origin from message', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const delegate = checksumAddress(faker.finance.ethereumAddress())
const messageItem = createMessageItem({ delegate, action: 'remove' })
// For remove action, the delegate must exist in proposers list (otherwise it's filtered out)
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [{ delegate, label: 'Existing Label' }] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [messageItem] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].action).toBe('remove')
expect(result.current.pendingDelegations[0].delegateAddress).toBe(delegate)
expect(result.current.pendingDelegations[0].delegateLabel).toBe('Test Proposer')
expect(result.current.pendingDelegations[0].nestedSafeAddress).toBe(safeAddress)
expect(result.current.pendingDelegations[0].parentSafeAddress).toBe(parentSafeAddress)
})
it('should derive pending status when confirmations < required', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const messageItem = createMessageItem({
confirmationsSubmitted: 1,
confirmationsRequired: 2,
})
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [messageItem] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations[0].status).toBe('pending')
})
it('should derive ready status when confirmations >= required', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const messageItem = createMessageItem({
confirmationsSubmitted: 2,
confirmationsRequired: 2,
})
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [messageItem] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations[0].status).toBe('ready')
})
it('should keep only the latest delegation per delegate', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const olderMessage = createMessageItem({
delegate: delegateAddress,
creationTimestamp: 1000,
messageHash: '0xolder',
})
const newerMessage = createMessageItem({
delegate: delegateAddress,
creationTimestamp: 2000,
messageHash: '0xnewer',
})
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [olderMessage, newerMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].messageHash).toBe('0xnewer')
})
it('should filter out add delegation when delegate already exists', () => {
const existingDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [{ delegate: existingDelegate, label: 'Existing Label' }] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const addMessage = createMessageItem({ delegate: existingDelegate, action: 'add' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [addMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(0)
})
it('should filter out remove delegation when delegate does not exist', () => {
const nonExistentDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const removeMessage = createMessageItem({ delegate: nonExistentDelegate, action: 'remove' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [removeMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(0)
})
it('should keep add delegation when delegate does not exist yet', () => {
const newDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const addMessage = createMessageItem({ delegate: newDelegate, action: 'add' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [addMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].action).toBe('add')
})
it('should keep remove delegation when delegate exists', () => {
const existingDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [{ delegate: existingDelegate, label: 'Existing Label' }] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const removeMessage = createMessageItem({ delegate: existingDelegate, action: 'remove' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [removeMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].action).toBe('remove')
})
it('should skip query when no parent safe address', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue(null)
const mockQuery = jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: undefined,
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
renderHook(() => usePendingDelegations())
expect(mockQuery).toHaveBeenCalledWith({ chainId, safeAddress: '' }, expect.objectContaining({ skip: true }))
})
it('should return refetch function', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.refetch).toBe(mockRefetch)
})
it('should filter out messages with invalid origin', () => {
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
const validMessage = createMessageItem()
const invalidOriginMessage: MessageItem = {
...createMessageItem(),
messageHash: '0xinvalid',
origin: 'not-a-json-origin',
}
const wrongTypeOriginMessage: MessageItem = {
...createMessageItem(),
messageHash: '0xwrongtype',
origin: JSON.stringify({ type: 'other-type', action: 'add' }),
}
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [validMessage, invalidOriginMessage, wrongTypeOriginMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].messageHash).toBe(validMessage.messageHash)
})
it('should keep edit delegation when delegate exists and label is different', () => {
const existingDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [{ delegate: existingDelegate, label: 'Old Label' }] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const editMessage = createMessageItem({ delegate: existingDelegate, action: 'edit', label: 'New Label' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [editMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(1)
expect(result.current.pendingDelegations[0].action).toBe('edit')
})
it('should filter out edit delegation when delegate does not exist', () => {
const nonExistentDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
const editMessage = createMessageItem({ delegate: nonExistentDelegate, action: 'edit', label: 'New Label' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [editMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(0)
})
it('should filter out edit delegation when label already matches (edit was applied)', () => {
const existingDelegate = checksumAddress(faker.finance.ethereumAddress())
jest.spyOn(useNestedSafeOwnersModule, 'useNestedSafeOwners').mockReturnValue([parentSafeAddress])
jest.spyOn(useProposersModule, 'default').mockReturnValue({
data: { results: [{ delegate: existingDelegate, label: 'Updated Label' }] },
isLoading: false,
refetch: jest.fn(),
} as unknown as ReturnType<typeof useProposersModule.default>)
// Pending edit with same label as current - means edit was already applied
const editMessage = createMessageItem({ delegate: existingDelegate, action: 'edit', label: 'Updated Label' })
jest.spyOn(messagesQueries, 'useMessagesGetMessagesBySafeV1Query').mockReturnValue({
data: { results: [editMessage] },
isLoading: false,
refetch: mockRefetch,
} as ReturnType<typeof messagesQueries.useMessagesGetMessagesBySafeV1Query>)
const { result } = renderHook(() => usePendingDelegations())
expect(result.current.pendingDelegations).toHaveLength(0)
})
})
@@ -0,0 +1,238 @@
import { renderHook, act, waitFor } from '@/tests/test-utils'
import { useSubmitDelegation } from '../useSubmitDelegation'
import * as useChainIdModule from '@/hooks/useChainId'
import * as useSafeAddressModule from '@/hooks/useSafeAddress'
import * as delegatesQueries from '@safe-global/store/gateway/AUTO_GENERATED/delegates'
import * as utilsModule from '@/features/proposers/utils/utils'
import { faker } from '@faker-js/faker'
import { checksumAddress } from '@safe-global/utils/utils/addresses'
import type { PendingDelegation } from '@/features/proposers/types'
describe('useSubmitDelegation', () => {
const chainId = '1'
const safeAddress = checksumAddress(faker.finance.ethereumAddress())
const parentSafeAddress = checksumAddress(faker.finance.ethereumAddress())
const delegateAddress = checksumAddress(faker.finance.ethereumAddress())
const preparedSignature = `0x${faker.string.hexadecimal({ length: 130 })}`
const encodedSignature = `0x${faker.string.hexadecimal({ length: 200 })}`
const createPendingDelegation = (overrides: Partial<PendingDelegation> = {}): PendingDelegation => ({
messageHash: `0x${faker.string.hexadecimal({ length: 64 })}`,
action: 'add',
delegateAddress,
delegateLabel: 'Test Proposer',
nestedSafeAddress: safeAddress,
parentSafeAddress,
totp: Math.floor(Date.now() / 1000 / 3600),
status: 'ready',
confirmationsSubmitted: 2,
confirmationsRequired: 2,
confirmations: [],
preparedSignature,
creationTimestamp: Date.now(),
proposedBy: { value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
...overrides,
})
let mockAddDelegateV2: jest.Mock
let mockDeleteDelegateV2: jest.Mock
beforeEach(() => {
jest.clearAllMocks()
jest.spyOn(useChainIdModule, 'default').mockReturnValue(chainId)
jest.spyOn(useSafeAddressModule, 'default').mockReturnValue(safeAddress)
jest.spyOn(utilsModule, 'encodeEIP1271Signature').mockResolvedValue(encodedSignature)
mockAddDelegateV2 = jest.fn().mockReturnValue({ unwrap: jest.fn().mockResolvedValue({}) })
mockDeleteDelegateV2 = jest.fn().mockReturnValue({ unwrap: jest.fn().mockResolvedValue({}) })
jest
.spyOn(delegatesQueries, 'useDelegatesPostDelegateV2Mutation')
.mockReturnValue([mockAddDelegateV2, { isLoading: false, reset: jest.fn() }] as unknown as ReturnType<
typeof delegatesQueries.useDelegatesPostDelegateV2Mutation
>)
jest
.spyOn(delegatesQueries, 'useDelegatesDeleteDelegateV2Mutation')
.mockReturnValue([mockDeleteDelegateV2, { isLoading: false, reset: jest.fn() }] as unknown as ReturnType<
typeof delegatesQueries.useDelegatesDeleteDelegateV2Mutation
>)
})
it('should throw error when preparedSignature is missing', async () => {
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ preparedSignature: null })
await expect(result.current.submitDelegation(delegation)).rejects.toThrow(
'Cannot submit delegation: preparedSignature is not available',
)
})
it('should call addDelegateV2 for add action with correct params', async () => {
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
await act(async () => {
await result.current.submitDelegation(delegation)
})
expect(utilsModule.encodeEIP1271Signature).toHaveBeenCalledWith(parentSafeAddress, preparedSignature)
expect(mockAddDelegateV2).toHaveBeenCalledWith({
chainId,
createDelegateDto: {
safe: safeAddress,
delegate: delegateAddress,
delegator: parentSafeAddress,
signature: encodedSignature,
label: 'Test Proposer',
},
})
expect(mockDeleteDelegateV2).not.toHaveBeenCalled()
})
it('should call deleteDelegateV2 for remove action with correct params', async () => {
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'remove' })
await act(async () => {
await result.current.submitDelegation(delegation)
})
expect(utilsModule.encodeEIP1271Signature).toHaveBeenCalledWith(parentSafeAddress, preparedSignature)
expect(mockDeleteDelegateV2).toHaveBeenCalledWith({
chainId,
delegateAddress,
deleteDelegateV2Dto: {
delegator: parentSafeAddress,
safe: safeAddress,
signature: encodedSignature,
},
})
expect(mockAddDelegateV2).not.toHaveBeenCalled()
})
it('should set isSubmitting to true during submission', async () => {
let resolvePromise: () => void
const pendingPromise = new Promise<void>((resolve) => {
resolvePromise = resolve
})
mockAddDelegateV2.mockReturnValue({ unwrap: () => pendingPromise })
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
expect(result.current.isSubmitting).toBe(false)
let submitPromise: Promise<void>
act(() => {
submitPromise = result.current.submitDelegation(delegation)
})
await waitFor(() => {
expect(result.current.isSubmitting).toBe(true)
})
await act(async () => {
resolvePromise!()
await submitPromise
})
expect(result.current.isSubmitting).toBe(false)
})
it('should set isSubmitting to false after success', async () => {
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
await act(async () => {
await result.current.submitDelegation(delegation)
})
expect(result.current.isSubmitting).toBe(false)
})
it('should set submitError on failure', async () => {
const error = new Error('Network error')
mockAddDelegateV2.mockReturnValue({ unwrap: jest.fn().mockRejectedValue(error) })
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
await act(async () => {
try {
await result.current.submitDelegation(delegation)
} catch {
// Expected
}
})
expect(result.current.submitError).toBe(error)
})
it('should re-throw error after setting submitError', async () => {
const error = new Error('Network error')
mockAddDelegateV2.mockReturnValue({ unwrap: jest.fn().mockRejectedValue(error) })
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
let thrownError: Error | undefined
await act(async () => {
try {
await result.current.submitDelegation(delegation)
} catch (e) {
thrownError = e as Error
}
})
expect(thrownError).toBeDefined()
expect(thrownError?.message).toBe('Network error')
})
it('should set isSubmitting to false after failure', async () => {
const error = new Error('Network error')
mockAddDelegateV2.mockReturnValue({ unwrap: jest.fn().mockRejectedValue(error) })
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
await act(async () => {
try {
await result.current.submitDelegation(delegation)
} catch {
// Expected
}
})
expect(result.current.isSubmitting).toBe(false)
})
it('should clear previous submitError on new submission', async () => {
const error = new Error('Network error')
mockAddDelegateV2
.mockReturnValueOnce({ unwrap: jest.fn().mockRejectedValue(error) })
.mockReturnValueOnce({ unwrap: jest.fn().mockResolvedValue({}) })
const { result } = renderHook(() => useSubmitDelegation())
const delegation = createPendingDelegation({ action: 'add' })
// First submission fails
await act(async () => {
try {
await result.current.submitDelegation(delegation)
} catch {
// Expected
}
})
expect(result.current.submitError).toBe(error)
// Second submission succeeds and clears the error
await act(async () => {
await result.current.submitDelegation(delegation)
})
expect(result.current.submitError).toBeUndefined()
})
})
@@ -0,0 +1,38 @@
import { useMemo } from 'react'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import useChainId from '@/hooks/useChainId'
import { useSafesGetSafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/safes'
/**
* Fetches the parent Safe's threshold and owners for the current nested Safe.
* Returns undefined if the current user is not a nested Safe owner or data is loading.
*/
export const useParentSafeThreshold = () => {
const nestedSafeOwners = useNestedSafeOwners()
const chainId = useChainId()
const parentSafeAddress = nestedSafeOwners?.[0]
const { data: parentSafe, isLoading } = useSafesGetSafeV1Query(
{
chainId,
safeAddress: parentSafeAddress || '',
},
{
skip: !parentSafeAddress,
},
)
return useMemo(() => {
if (!parentSafeAddress || !parentSafe) {
return { threshold: undefined, owners: undefined, parentSafeAddress: undefined, isLoading }
}
return {
threshold: parentSafe.threshold,
owners: parentSafe.owners,
parentSafeAddress,
isLoading,
}
}, [parentSafe, parentSafeAddress, isLoading])
}
@@ -0,0 +1,68 @@
import { useMemo } from 'react'
import { useMessagesGetMessagesBySafeV1Query } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import { useNestedSafeOwners } from '@/hooks/useNestedSafeOwners'
import useProposers from '@/hooks/useProposers'
import { parseMessageToDelegation, type DelegationWithTimestamp } from '@/features/proposers/utils/delegationParsing'
import { keepLatestPerDelegate, filterActedUponDelegations } from '@/features/proposers/utils/delegationFilters'
import type { PendingDelegation } from '@/features/proposers/types'
import { DELEGATION_POLLING_INTERVAL_MS } from '@/features/proposers/constants'
type UsePendingDelegationsResult = {
pendingDelegations: PendingDelegation[]
isLoading: boolean
refetch: () => void
}
/**
* Fetches pending delegation messages from the parent Safe and filters for the current nested Safe.
* Returns parsed PendingDelegation objects with derived status.
* Uses RTK Query's built-in polling with a fixed 5 second interval.
*/
export function usePendingDelegations(): UsePendingDelegationsResult {
const chainId = useChainId()
const safeAddress = useSafeAddress()
const nestedSafeOwners = useNestedSafeOwners()
const parentSafeAddress = nestedSafeOwners?.[0]
const proposers = useProposers()
const {
data: messagesPage,
isLoading,
refetch,
} = useMessagesGetMessagesBySafeV1Query(
{
chainId,
safeAddress: parentSafeAddress || '',
},
{
skip: !parentSafeAddress,
pollingInterval: DELEGATION_POLLING_INTERVAL_MS,
},
)
// Map of lowercase address -> label for current delegates
const currentDelegatesMap = useMemo(() => {
const map = new Map<string, string>()
for (const p of proposers.data?.results ?? []) {
map.set(p.delegate.toLowerCase(), p.label)
}
return map
}, [proposers.data?.results])
const pendingDelegations = useMemo(() => {
if (!messagesPage?.results || !parentSafeAddress) return []
const allDelegations = messagesPage.results
.filter((item): item is MessageItem => item.type === 'MESSAGE')
.map((message) => parseMessageToDelegation(message, safeAddress, parentSafeAddress))
.filter((d): d is DelegationWithTimestamp => d !== null)
const latestByDelegate = keepLatestPerDelegate(allDelegations)
return filterActedUponDelegations(latestByDelegate, currentDelegatesMap)
}, [messagesPage, parentSafeAddress, safeAddress, currentDelegatesMap])
return { pendingDelegations, isLoading, refetch }
}
@@ -0,0 +1,78 @@
import { useCallback, useState } from 'react'
import {
useDelegatesPostDelegateV2Mutation,
useDelegatesDeleteDelegateV2Mutation,
} from '@safe-global/store/gateway/AUTO_GENERATED/delegates'
import { asError } from '@safe-global/utils/services/exceptions/utils'
import { encodeEIP1271Signature } from '@/features/proposers/utils/utils'
import { isTotpValid } from '@/features/proposers/utils/totp'
import useChainId from '@/hooks/useChainId'
import useSafeAddress from '@/hooks/useSafeAddress'
import type { PendingDelegation } from '@/features/proposers/types'
/**
* Submits a confirmed delegation (threshold met) to the delegate API.
* Wraps the preparedSignature in EIP-1271 format and calls the appropriate endpoint.
*/
export const useSubmitDelegation = () => {
const chainId = useChainId()
const safeAddress = useSafeAddress()
const [addDelegateV2] = useDelegatesPostDelegateV2Mutation()
const [deleteDelegateV2] = useDelegatesDeleteDelegateV2Mutation()
const [isSubmitting, setIsSubmitting] = useState(false)
const [submitError, setSubmitError] = useState<Error>()
const submitDelegation = useCallback(
async (delegation: PendingDelegation) => {
if (!isTotpValid(delegation.totp)) {
throw new Error('Delegation has expired. Please create a new delegation request.')
}
if (!delegation.preparedSignature) {
throw new Error('Cannot submit delegation: preparedSignature is not available')
}
setIsSubmitting(true)
setSubmitError(undefined)
try {
const eip1271Signature = await encodeEIP1271Signature(
delegation.parentSafeAddress,
delegation.preparedSignature,
)
if (delegation.action === 'add' || delegation.action === 'edit') {
await addDelegateV2({
chainId,
createDelegateDto: {
safe: safeAddress,
delegate: delegation.delegateAddress,
delegator: delegation.parentSafeAddress,
signature: eip1271Signature,
label: delegation.delegateLabel,
},
}).unwrap()
} else if (delegation.action === 'remove') {
await deleteDelegateV2({
chainId,
delegateAddress: delegation.delegateAddress,
deleteDelegateV2Dto: {
delegator: delegation.parentSafeAddress,
safe: safeAddress,
signature: eip1271Signature,
},
}).unwrap()
}
} catch (error) {
const err = asError(error)
setSubmitError(err)
throw err
} finally {
setIsSubmitting(false)
}
},
[chainId, safeAddress, addDelegateV2, deleteDelegateV2],
)
return { submitDelegation, isSubmitting, submitError }
}
@@ -0,0 +1,274 @@
import { buildDelegationOrigin, createDelegationMessage, confirmDelegationMessage } from '../delegationMessages'
import type { TypedData, MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import { faker } from '@faker-js/faker'
import { checksumAddress } from '@safe-global/utils/utils/addresses'
import type { AppDispatch } from '@/store'
describe('delegationMessages', () => {
const chainId = '1'
const parentSafeAddress = checksumAddress(faker.finance.ethereumAddress())
const delegateAddress = checksumAddress(faker.finance.ethereumAddress())
const nestedSafeAddress = checksumAddress(faker.finance.ethereumAddress())
const signature = `0x${faker.string.hexadecimal({ length: 130 })}`
const messageHash = `0x${faker.string.hexadecimal({ length: 64 })}`
const createTypedData = (delegate: string = delegateAddress): TypedData => ({
domain: { chainId: Number(chainId) },
types: { Delegate: [{ name: 'delegateAddress', type: 'address' }] },
primaryType: 'Delegate',
message: { delegateAddress: delegate },
})
describe('buildDelegationOrigin', () => {
it('should build correct JSON string for add action', () => {
const result = buildDelegationOrigin('add', delegateAddress, nestedSafeAddress, 'Test Label')
const parsed = JSON.parse(result)
expect(parsed).toEqual({
type: 'proposer-delegation',
action: 'add',
delegate: delegateAddress,
nestedSafe: nestedSafeAddress,
label: 'Test Label',
})
})
it('should build correct JSON string for remove action', () => {
const result = buildDelegationOrigin('remove', delegateAddress, nestedSafeAddress, 'Remove Label')
const parsed = JSON.parse(result)
expect(parsed).toEqual({
type: 'proposer-delegation',
action: 'remove',
delegate: delegateAddress,
nestedSafe: nestedSafeAddress,
label: 'Remove Label',
})
})
it('should include all required fields for proper delegation origin', () => {
const result = buildDelegationOrigin('add', delegateAddress, nestedSafeAddress, 'My Label')
const parsed = JSON.parse(result)
expect(parsed.type).toBe('proposer-delegation')
expect(parsed.action).toBeDefined()
expect(parsed.delegate).toBeDefined()
expect(parsed.nestedSafe).toBeDefined()
expect(parsed.label).toBeDefined()
})
})
describe('createDelegationMessage', () => {
const typedData = createTypedData()
const origin = buildDelegationOrigin('add', delegateAddress, nestedSafeAddress, 'Test')
it('should successfully create message by dispatching correct action', async () => {
const mockUnwrap = jest.fn().mockResolvedValue({})
const mockDispatch = jest.fn().mockReturnValue({ unwrap: mockUnwrap })
await createDelegationMessage(
mockDispatch as unknown as AppDispatch,
chainId,
parentSafeAddress,
typedData,
signature,
origin,
)
expect(mockDispatch).toHaveBeenCalled()
expect(mockUnwrap).toHaveBeenCalled()
})
it('should confirm existing message when 400 "already exists" error with same action', async () => {
const existingMessage: MessageItem = {
type: 'MESSAGE',
messageHash,
status: 'NEEDS_CONFIRMATION',
logoUri: null,
name: null,
message: {
domain: { chainId: Number(chainId) },
types: { Delegate: [{ name: 'delegateAddress', type: 'address' }] },
primaryType: 'Delegate',
message: { delegateAddress: delegateAddress.toLowerCase() },
},
creationTimestamp: Date.now(),
modifiedTimestamp: Date.now(),
confirmationsSubmitted: 1,
confirmationsRequired: 2,
proposedBy: { value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
confirmations: [],
preparedSignature: null,
origin: JSON.stringify({
type: 'proposer-delegation',
action: 'add',
delegate: delegateAddress,
nestedSafe: nestedSafeAddress,
label: 'Test',
}),
}
let callCount = 0
const mockDispatch = jest.fn().mockImplementation(() => {
callCount++
if (callCount === 1) {
// First call: messagesCreateMessageV1 - fails with 400
return {
unwrap: jest.fn().mockRejectedValue({
status: 400,
data: { message: 'Message already exists for that Safe' },
}),
}
} else if (callCount === 2) {
// Second call: messagesGetMessagesBySafeV1 - returns existing message
return {
unwrap: jest.fn().mockResolvedValue({
results: [existingMessage],
}),
}
} else {
// Third call: messagesUpdateMessageSignatureV1 - succeeds
return {
unwrap: jest.fn().mockResolvedValue({}),
}
}
})
await createDelegationMessage(
mockDispatch as unknown as AppDispatch,
chainId,
parentSafeAddress,
typedData,
signature,
origin,
)
// Should have called 3 times: create (failed), get (found), update (confirmed)
expect(mockDispatch).toHaveBeenCalledTimes(3)
})
it('should throw descriptive error when 400 "already exists" error with different action', async () => {
const existingMessage: MessageItem = {
type: 'MESSAGE',
messageHash,
status: 'NEEDS_CONFIRMATION',
logoUri: null,
name: null,
message: {
domain: { chainId: Number(chainId) },
types: { Delegate: [{ name: 'delegateAddress', type: 'address' }] },
primaryType: 'Delegate',
message: { delegateAddress: delegateAddress.toLowerCase() },
},
creationTimestamp: Date.now(),
modifiedTimestamp: Date.now(),
confirmationsSubmitted: 1,
confirmationsRequired: 2,
proposedBy: { value: checksumAddress(faker.finance.ethereumAddress()), name: null, logoUri: null },
confirmations: [],
preparedSignature: null,
origin: JSON.stringify({
type: 'proposer-delegation',
action: 'remove', // Different action
delegate: delegateAddress,
nestedSafe: nestedSafeAddress,
label: 'Test',
}),
}
let callCount = 0
const mockDispatch = jest.fn().mockImplementation(() => {
callCount++
if (callCount === 1) {
return {
unwrap: jest.fn().mockRejectedValue({
status: 400,
data: { message: 'Message already exists for that Safe' },
}),
}
} else {
return {
unwrap: jest.fn().mockResolvedValue({
results: [existingMessage],
}),
}
}
})
await expect(
createDelegationMessage(
mockDispatch as unknown as AppDispatch,
chainId,
parentSafeAddress,
typedData,
signature,
origin,
),
).rejects.toThrow(
'A pending "remove" delegation already exists for this proposer. Please wait for it to expire (~1 hour) before initiating a "add" action.',
)
})
it('should re-throw other errors', async () => {
const networkError = new Error('Network error')
const mockDispatch = jest.fn().mockReturnValue({
unwrap: jest.fn().mockRejectedValue(networkError),
})
await expect(
createDelegationMessage(
mockDispatch as unknown as AppDispatch,
chainId,
parentSafeAddress,
typedData,
signature,
origin,
),
).rejects.toThrow('Network error')
})
it('should re-throw 400 errors that are not "already exists"', async () => {
const validationError = {
status: 400,
data: { message: 'Invalid signature' },
}
const mockDispatch = jest.fn().mockReturnValue({
unwrap: jest.fn().mockRejectedValue(validationError),
})
await expect(
createDelegationMessage(
mockDispatch as unknown as AppDispatch,
chainId,
parentSafeAddress,
typedData,
signature,
origin,
),
).rejects.toEqual(validationError)
})
})
describe('confirmDelegationMessage', () => {
it('should dispatch messagesUpdateMessageSignatureV1 action', async () => {
const mockUnwrap = jest.fn().mockResolvedValue({})
const mockDispatch = jest.fn().mockReturnValue({ unwrap: mockUnwrap })
await confirmDelegationMessage(mockDispatch as unknown as AppDispatch, chainId, messageHash, signature)
expect(mockDispatch).toHaveBeenCalled()
expect(mockUnwrap).toHaveBeenCalled()
})
it('should throw error when confirmation fails', async () => {
const error = new Error('Confirmation failed')
const mockDispatch = jest.fn().mockReturnValue({
unwrap: jest.fn().mockRejectedValue(error),
})
await expect(
confirmDelegationMessage(mockDispatch as unknown as AppDispatch, chainId, messageHash, signature),
).rejects.toThrow('Confirmation failed')
})
})
})
@@ -0,0 +1,129 @@
import type { DelegationOrigin } from '@/features/proposers/types'
import type { TypedData, CreateMessageDto } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import { cgwApi } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import { normalizeTypedData } from '@safe-global/utils/utils/web3'
import { sameAddress } from '@safe-global/utils/utils/addresses'
import { parseDelegationOrigin } from '@/features/proposers/utils/delegationParsing'
import type { AppDispatch } from '@/store'
/**
* Builds the origin metadata JSON string for a delegation off-chain message.
*/
export function buildDelegationOrigin(
action: 'add' | 'remove' | 'edit',
delegate: string,
nestedSafe: string,
label: string,
): string {
const origin: DelegationOrigin = {
type: 'proposer-delegation',
action,
delegate,
nestedSafe,
label,
}
return JSON.stringify(origin)
}
/**
* Creates a new off-chain delegation message on the parent Safe.
* This initiates the multi-sig signature collection process.
*
* If a message already exists for the same delegate/totp:
* - If the action matches, confirms the existing message instead
* - If the action differs, throws an error (can't have add + remove in same TOTP window)
*/
export async function createDelegationMessage(
dispatch: AppDispatch,
chainId: string,
parentSafeAddress: string,
delegateTypedData: TypedData,
signature: string,
origin: string,
): Promise<void> {
const typedDataDelegate = (delegateTypedData.message as { delegateAddress?: string })?.delegateAddress
const parsedOrigin = parseDelegationOrigin(origin)
if (typedDataDelegate && parsedOrigin && !sameAddress(typedDataDelegate, parsedOrigin.delegate)) {
throw new Error('Security error: Origin delegate does not match typed data')
}
const normalizedMessage = normalizeTypedData(delegateTypedData)
const requestedAction = parsedOrigin?.action ?? null
const createMessageDto: CreateMessageDto = {
message: normalizedMessage,
signature,
safeAppId: null,
origin,
}
try {
await dispatch(
cgwApi.endpoints.messagesCreateMessageV1.initiate({
chainId,
safeAddress: parentSafeAddress,
createMessageDto,
}),
).unwrap()
} catch (error: unknown) {
// Check if message already exists (400 error)
const err = error as { status?: number; data?: { message?: string } }
if (err.status === 400 && err.data?.message?.includes('already exists')) {
// Fetch existing messages to find the conflicting one
const messagesResult = await dispatch(
cgwApi.endpoints.messagesGetMessagesBySafeV1.initiate({
chainId,
safeAddress: parentSafeAddress,
}),
).unwrap()
// Find the existing message for this delegate
const existingMessage = messagesResult.results?.find((msg) => {
if (msg.type !== 'MESSAGE') return false
const msgOrigin = parseDelegationOrigin(msg.origin)
const msgDelegate = (msg.message as { message?: { delegateAddress?: string } })?.message?.delegateAddress
return sameAddress(msgDelegate, typedDataDelegate) && msgOrigin !== null
})
if (existingMessage && existingMessage.type === 'MESSAGE') {
const existingAction = parseDelegationOrigin(existingMessage.origin)?.action
if (existingAction === requestedAction) {
// Same action - confirm the existing message instead
await confirmDelegationMessage(dispatch, chainId, existingMessage.messageHash, signature)
return
} else {
// Different action - can't have add + remove in same TOTP window
throw new Error(
`A pending "${existingAction}" delegation already exists for this proposer. ` +
`Please wait for it to expire (~1 hour) before initiating a "${requestedAction}" action.`,
)
}
} else {
// Message conflict reported but existing message not found - rethrow original error
throw error
}
}
// Re-throw other errors
throw error
}
}
/**
* Adds a co-owner's signature to an existing delegation message.
*/
export async function confirmDelegationMessage(
dispatch: AppDispatch,
chainId: string,
messageHash: string,
signature: string,
): Promise<void> {
await dispatch(
cgwApi.endpoints.messagesUpdateMessageSignatureV1.initiate({
chainId,
messageHash,
updateMessageSignatureDto: { signature },
}),
).unwrap()
}
+26
View File
@@ -0,0 +1,26 @@
import type { AddressInfo, MessageConfirmation } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
export interface DelegationOrigin {
type: 'proposer-delegation'
action: 'add' | 'remove' | 'edit'
delegate: string
nestedSafe: string
label: string
}
export interface PendingDelegation {
messageHash: string
action: 'add' | 'remove' | 'edit'
delegateAddress: string
delegateLabel: string
nestedSafeAddress: string
parentSafeAddress: string
totp: number
status: 'pending' | 'ready' | 'expired'
confirmationsSubmitted: number
confirmationsRequired: number
confirmations: MessageConfirmation[]
preparedSignature: string | null
creationTimestamp: number
proposedBy: AddressInfo
}
@@ -0,0 +1,53 @@
import type { PendingDelegation } from '@/features/proposers/types'
import type { DelegationWithTimestamp } from './delegationParsing'
/**
* Keeps only the latest delegation per delegate address.
* Uses lowercase address as key for consistent comparison.
*/
export function keepLatestPerDelegate(delegations: DelegationWithTimestamp[]): Map<string, DelegationWithTimestamp> {
const latestByDelegate = new Map<string, DelegationWithTimestamp>()
for (const delegation of delegations) {
const key = delegation.delegateAddress.toLowerCase()
const existing = latestByDelegate.get(key)
if (!existing || delegation._timestamp > existing._timestamp) {
latestByDelegate.set(key, delegation)
}
}
return latestByDelegate
}
/**
* Filters out delegations that have already been acted upon.
* - "add" delegations are filtered if the delegate is already in currentDelegates
* - "edit" delegations are filtered if the delegate doesn't exist OR the label already matches (edit applied)
* - "remove" delegations are filtered if the delegate is not in currentDelegates
*
* @param currentDelegates Map of lowercase delegate address -> current label
*/
export function filterActedUponDelegations(
delegations: Map<string, DelegationWithTimestamp>,
currentDelegates: Map<string, string>,
): PendingDelegation[] {
const result: PendingDelegation[] = []
for (const delegation of delegations.values()) {
const delegateLower = delegation.delegateAddress.toLowerCase()
const currentLabel = currentDelegates.get(delegateLower)
const isAlreadyDelegate = currentLabel !== undefined
if (delegation.action === 'add' && isAlreadyDelegate) continue
if (delegation.action === 'edit') {
// Filter if delegate doesn't exist OR if label already matches (edit was applied)
if (!isAlreadyDelegate || currentLabel === delegation.delegateLabel) continue
}
if (delegation.action === 'remove' && !isAlreadyDelegate) continue
const { _timestamp: _, ...cleanDelegation } = delegation
result.push(cleanDelegation)
}
return result
}
@@ -0,0 +1,94 @@
import type { MessageItem } from '@safe-global/store/gateway/AUTO_GENERATED/messages'
import { sameAddress } from '@safe-global/utils/utils/addresses'
import { isAddress } from 'ethers'
import { isTotpValid } from '@/features/proposers/utils/totp'
import type { DelegationOrigin, PendingDelegation } from '@/features/proposers/types'
export type DelegationWithTimestamp = PendingDelegation & { _timestamp: number }
/**
* Parses the origin JSON string from a message and validates it's a delegation origin.
*/
export function parseDelegationOrigin(originStr: string | null | undefined): DelegationOrigin | null {
if (!originStr) return null
try {
const parsed = JSON.parse(originStr)
if (
parsed?.type === 'proposer-delegation' &&
(parsed.action === 'add' || parsed.action === 'remove' || parsed.action === 'edit') &&
typeof parsed.delegate === 'string' &&
isAddress(parsed.delegate) &&
typeof parsed.nestedSafe === 'string' &&
isAddress(parsed.nestedSafe) &&
typeof parsed.label === 'string'
) {
return parsed as DelegationOrigin
}
} catch {
// Invalid JSON - not a delegation origin
}
return null
}
/**
* Derives the delegation status based on confirmations and TOTP validity.
*/
export function deriveDelegationStatus(
confirmationsSubmitted: number,
confirmationsRequired: number,
messageTotp: number,
): 'pending' | 'ready' | 'expired' {
if (!isTotpValid(messageTotp)) return 'expired'
if (confirmationsSubmitted >= confirmationsRequired) return 'ready'
return 'pending'
}
/**
* Extracts the TOTP value from a typed data message.
* Returns undefined if the TOTP cannot be extracted or is invalid.
*/
export function extractTotpFromMessage(message: MessageItem['message']): number | undefined {
const typedDataMessage = typeof message === 'object' ? message : null
const rawTotp = typedDataMessage?.message?.totp
if (rawTotp === undefined) return undefined
const totp = Number(rawTotp)
return isNaN(totp) ? undefined : totp
}
/**
* Parses a message item into a delegation object if it's a valid delegation for the given Safe.
*/
export function parseMessageToDelegation(
message: MessageItem,
safeAddress: string,
parentSafeAddress: string,
): DelegationWithTimestamp | null {
const origin = parseDelegationOrigin(message.origin)
if (!origin || !sameAddress(origin.nestedSafe, safeAddress)) {
return null
}
const messageTotp = extractTotpFromMessage(message.message)
if (messageTotp === undefined) return null
const status = deriveDelegationStatus(message.confirmationsSubmitted, message.confirmationsRequired, messageTotp)
if (status === 'expired') return null
return {
messageHash: message.messageHash,
action: origin.action,
delegateAddress: origin.delegate,
delegateLabel: origin.label,
nestedSafeAddress: origin.nestedSafe,
parentSafeAddress,
totp: messageTotp,
status,
confirmationsSubmitted: message.confirmationsSubmitted,
confirmationsRequired: message.confirmationsRequired,
confirmations: message.confirmations,
preparedSignature: message.preparedSignature ?? null,
creationTimestamp: message.creationTimestamp,
proposedBy: message.proposedBy,
_timestamp: message.creationTimestamp,
}
}
@@ -0,0 +1,27 @@
import { TOTP_INTERVAL_SECONDS } from '@/features/proposers/constants'
/**
* Returns the current TOTP value (hour-based).
* The delegate API uses totp = floor(now / 3600) as a time-based nonce.
*/
export const getCurrentTotp = (): number => {
return Math.floor(Date.now() / 1000 / TOTP_INTERVAL_SECONDS)
}
/**
* Checks if a TOTP value from a delegation message is still valid.
* The backend accepts current TOTP ± 1 previous interval (~2 hour window).
*/
export const isTotpValid = (messageTotp: number): boolean => {
const currentTotp = getCurrentTotp()
return currentTotp - messageTotp <= 1
}
/**
* Computes the expiration date for a TOTP-based delegation.
* The TOTP is valid for current interval + 1 previous interval.
* So expiration is at (totp + 2) intervals from the epoch.
*/
export const getTotpExpirationDate = (totp: number): Date => {
return new Date((totp + 2) * TOTP_INTERVAL_SECONDS * 1000)
}
@@ -0,0 +1,212 @@
import { encodeEIP1271Signature, signProposerTypedDataForSafe } from './utils'
import { faker } from '@faker-js/faker'
import { getAddress } from 'ethers'
import type { JsonRpcSigner } from 'ethers'
import * as web3Utils from '@safe-global/utils/utils/web3'
import * as delegateUtils from '@safe-global/utils/services/delegates'
describe('encodeEIP1271Signature', () => {
const parentSafeAddress = getAddress(faker.finance.ethereumAddress())
// A typical 65-byte ECDSA signature (r + s + v)
const ownerSignature =
'0x' +
'a'.repeat(64) + // r (32 bytes)
'b'.repeat(64) + // s (32 bytes)
'1c' // v (1 byte = 28)
it('should return a valid hex string', async () => {
const result = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
expect(result).toMatch(/^0x[0-9a-fA-F]+$/)
})
it('should contain the parent Safe address left-padded to 32 bytes in the r-value', async () => {
const result = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
// r is bytes 0-31 (hex chars 2-66, after "0x")
const rValue = result.slice(2, 66)
// parentSafeAddress is 20 bytes, left-padded with 12 zero bytes (24 hex chars)
const expectedR = '0'.repeat(24) + parentSafeAddress.slice(2).toLowerCase()
expect(rValue.toLowerCase()).toBe(expectedR.toLowerCase())
})
it('should have s-value of 65 (0x41) left-padded to 32 bytes', async () => {
const result = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
// s is bytes 32-63 (hex chars 66-130)
const sValue = result.slice(66, 130)
// 65 decimal = 0x41, left-padded to 32 bytes
const expectedS = '0'.repeat(62) + '41'
expect(sValue).toBe(expectedS)
})
it('should have v-value of 0x00 (contract signature type)', async () => {
const result = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
// v is byte 64 (hex chars 130-132)
const vValue = result.slice(130, 132)
expect(vValue).toBe('00')
})
it('should include length-prefixed owner signature in the dynamic data portion', async () => {
const result = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
// Dynamic data starts at byte 65 (hex char 132)
const dynamicData = result.slice(132)
// The dynamic data contains the length-prefixed owner signature
// Length of ownerSignature = 65 bytes = 0x41
const lengthHex = dynamicData.slice(0, 64)
expect(parseInt(lengthHex, 16)).toBe(65) // 65 bytes for ECDSA signature
// The actual signature data follows the length
const sigData = dynamicData.slice(64, 64 + 130) // 65 bytes = 130 hex chars
expect(sigData.toLowerCase()).toBe(ownerSignature.slice(2).toLowerCase())
})
it('should produce consistent output for the same inputs', async () => {
const result1 = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
const result2 = await encodeEIP1271Signature(parentSafeAddress, ownerSignature)
expect(result1).toBe(result2)
})
it('should correctly encode multi-owner preparedSignature (2 concatenated 65-byte signatures)', async () => {
// Two 65-byte signatures concatenated (as returned by preparedSignature for a 2/N Safe)
const sig1 = 'a'.repeat(64) + 'b'.repeat(64) + '1b' // 65 bytes
const sig2 = 'c'.repeat(64) + 'd'.repeat(64) + '1c' // 65 bytes
const multiOwnerSignature = '0x' + sig1 + sig2 // 130 bytes total
const result = await encodeEIP1271Signature(parentSafeAddress, multiOwnerSignature)
expect(result).toMatch(/^0x[0-9a-fA-F]+$/)
// r: parent Safe address
const rValue = result.slice(2, 66)
const expectedR = '0'.repeat(24) + parentSafeAddress.slice(2).toLowerCase()
expect(rValue.toLowerCase()).toBe(expectedR.toLowerCase())
// s: offset 65
const sValue = result.slice(66, 130)
expect(sValue).toBe('0'.repeat(62) + '41')
// v: 0x00
expect(result.slice(130, 132)).toBe('00')
// Dynamic data: length should be 130 bytes (2 * 65)
const dynamicData = result.slice(132)
const lengthHex = dynamicData.slice(0, 64)
expect(parseInt(lengthHex, 16)).toBe(130)
// The actual multi-owner signature data
const sigData = dynamicData.slice(64, 64 + 260) // 130 bytes = 260 hex chars
expect(sigData.toLowerCase()).toBe((sig1 + sig2).toLowerCase())
})
it('should correctly encode multi-owner preparedSignature (3 concatenated 65-byte signatures)', async () => {
// Three 65-byte signatures concatenated (as returned by preparedSignature for a 3/N Safe)
const sig1 = '1'.repeat(130) // 65 bytes
const sig2 = '2'.repeat(130) // 65 bytes
const sig3 = '3'.repeat(130) // 65 bytes
const multiOwnerSignature = '0x' + sig1 + sig2 + sig3 // 195 bytes total
const result = await encodeEIP1271Signature(parentSafeAddress, multiOwnerSignature)
// Dynamic data: length should be 195 bytes
const dynamicData = result.slice(132)
const lengthHex = dynamicData.slice(0, 64)
expect(parseInt(lengthHex, 16)).toBe(195)
// The actual multi-owner signature data
const sigData = dynamicData.slice(64, 64 + 390) // 195 bytes = 390 hex chars
expect(sigData.toLowerCase()).toBe((sig1 + sig2 + sig3).toLowerCase())
})
})
describe('signProposerTypedDataForSafe', () => {
const mockChainId = '11155111'
const mockProposerAddress = getAddress(faker.finance.ethereumAddress())
const mockParentSafeAddress = getAddress(faker.finance.ethereumAddress())
const mockSignature = '0x' + 'ab'.repeat(65)
const mockDelegateHash = '0x' + 'dd'.repeat(32)
const mockSigner = {} as JsonRpcSigner
beforeEach(() => {
jest.clearAllMocks()
})
it('should hash the delegate typed data and sign the SafeMessage-wrapped hash', async () => {
jest.spyOn(web3Utils, 'hashTypedData').mockReturnValue(mockDelegateHash)
jest.spyOn(web3Utils, 'signTypedData').mockResolvedValue(mockSignature)
const result = await signProposerTypedDataForSafe(
mockChainId,
mockProposerAddress,
mockParentSafeAddress,
mockSigner,
)
// Should hash the delegate typed data first
expect(web3Utils.hashTypedData).toHaveBeenCalledWith(
delegateUtils.getDelegateTypedData(mockChainId, mockProposerAddress),
)
// Should sign the SafeMessage typed data (not the raw delegate typed data)
expect(web3Utils.signTypedData).toHaveBeenCalledWith(
mockSigner,
expect.objectContaining({
domain: {
verifyingContract: mockParentSafeAddress,
chainId: Number(mockChainId),
},
types: {
SafeMessage: [{ type: 'bytes', name: 'message' }],
},
message: {
message: mockDelegateHash,
},
primaryType: 'SafeMessage',
}),
)
expect(result).toBe(mockSignature)
})
it('should use the correct parent Safe address in the domain', async () => {
const specificParentSafe = getAddress(faker.finance.ethereumAddress())
jest.spyOn(web3Utils, 'hashTypedData').mockReturnValue(mockDelegateHash)
jest.spyOn(web3Utils, 'signTypedData').mockResolvedValue(mockSignature)
await signProposerTypedDataForSafe(mockChainId, mockProposerAddress, specificParentSafe, mockSigner)
expect(web3Utils.signTypedData).toHaveBeenCalledWith(
mockSigner,
expect.objectContaining({
domain: expect.objectContaining({
verifyingContract: specificParentSafe,
}),
}),
)
})
it('should use the correct chainId in the domain', async () => {
jest.spyOn(web3Utils, 'hashTypedData').mockReturnValue(mockDelegateHash)
jest.spyOn(web3Utils, 'signTypedData').mockResolvedValue(mockSignature)
await signProposerTypedDataForSafe('1', mockProposerAddress, mockParentSafeAddress, mockSigner)
expect(web3Utils.signTypedData).toHaveBeenCalledWith(
mockSigner,
expect.objectContaining({
domain: expect.objectContaining({
chainId: 1,
}),
}),
)
})
})
+65 -3
View File
@@ -1,16 +1,58 @@
import { signTypedData } from '@safe-global/utils/utils/web3'
import { SigningMethod } from '@safe-global/protocol-kit'
import { hashTypedData, signTypedData } from '@safe-global/utils/utils/web3'
import { SigningMethod, EthSafeSignature, buildContractSignature, buildSignatureBytes } from '@safe-global/protocol-kit'
import { adjustVInSignature } from '@safe-global/protocol-kit/dist/src/utils/signatures'
import type { JsonRpcSigner } from 'ethers'
import { getDelegateTypedData } from '@safe-global/utils/services/delegates'
import { TOTP_INTERVAL_SECONDS } from '@/features/proposers/constants'
export const signProposerTypedData = async (chainId: string, proposerAddress: string, signer: JsonRpcSigner) => {
const typedData = getDelegateTypedData(chainId, proposerAddress)
return signTypedData(signer, typedData)
}
/**
* Signs the delegate typed data as a Safe message for EIP-1271 validation.
*
* When the parent Safe's isValidSignature is called with the delegate hash,
* the CompatibilityFallbackHandler wraps it in a SafeMessage EIP-712 structure:
* domain: { verifyingContract: parentSafeAddress, chainId }
* types: { SafeMessage: [{ type: 'bytes', name: 'message' }] }
* message: { message: delegateTypedDataHash }
*
* The EOA owner must sign this wrapped typed data so that checkSignatures
* can recover the signer correctly.
*/
export const signProposerTypedDataForSafe = async (
chainId: string,
proposerAddress: string,
parentSafeAddress: string,
signer: JsonRpcSigner,
) => {
// Step 1: Compute the delegate typed data hash
const delegateTypedData = getDelegateTypedData(chainId, proposerAddress)
const delegateHash = hashTypedData(delegateTypedData)
// Step 2: Build the SafeMessage typed data that the CompatibilityFallbackHandler uses
const safeMessageTypedData = {
domain: {
verifyingContract: parentSafeAddress,
chainId: Number(chainId),
},
types: {
SafeMessage: [{ type: 'bytes', name: 'message' }],
},
message: {
message: delegateHash,
},
primaryType: 'SafeMessage' as const,
}
// Step 3: Sign the SafeMessage typed data with the EOA
return signTypedData(signer, safeMessageTypedData)
}
const getProposerDataV1 = (proposerAddress: string) => {
const totp = Math.floor(Date.now() / 1000 / 3600)
const totp = Math.floor(Date.now() / 1000 / TOTP_INTERVAL_SECONDS)
return `${proposerAddress}${totp}`
}
@@ -22,3 +64,23 @@ export const signProposerData = async (proposerAddress: string, signer: JsonRpcS
return adjustVInSignature(SigningMethod.ETH_SIGN_TYPED_DATA, signature)
}
/**
* Encodes an EOA signature in EIP-1271 contract signature format for a parent Safe.
*
* Uses Safe Protocol Kit's buildContractSignature to create the proper format:
* - bytes 0-31: r = parentSafeAddress (left-padded to 32 bytes)
* - bytes 32-63: s = offset to dynamic signature data
* - byte 64: v = 0x00 (contract signature type)
* - bytes 65+: length-prefixed owner signature(s)
*/
export const encodeEIP1271Signature = async (parentSafeAddress: string, ownerSignature: string): Promise<string> => {
// Create a SafeSignature object from the raw owner signature
const ownerSig = new EthSafeSignature(parentSafeAddress, ownerSignature, false)
// Build the contract signature wrapper for EIP-1271 validation
const contractSig = await buildContractSignature([ownerSig], parentSafeAddress)
// Encode to the final signature bytes string
return '0x' + buildSignatureBytes([contractSig]).slice(2)
}
@@ -71,6 +71,7 @@ enum ErrorCodes {
_817 = '817: Error sending a transaction through nested Safe provider',
_818 = '818: Error validating transaction data',
_819 = '819: Error adding a transaction to the batch',
_820 = '820: Error signing or submitting delegation',
_900 = '900: Error loading Safe App',
_901 = '901: Error processing Safe Apps SDK request',
@@ -0,0 +1,35 @@
# Specification Quality Checklist: Nested Safe Proposer Management
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- All items pass validation. Specification is ready for `/speckit.clarify` or `/speckit.plan`.
- The feature is well-scoped as a permission check fix — the existing nested Safe owner detection and proposer signing infrastructure already exist in the codebase.
@@ -0,0 +1,139 @@
# API Contract: Delegate (Proposer) Management
**Date**: 2026-01-23
**Feature**: 001-nested-safe-proposer
## Existing Endpoints (No Changes)
The delegate API endpoints are already implemented by the CGW (Client Gateway) backend. This feature uses them with a new delegator type (Safe address instead of EOA).
### POST /v2/chains/{chainId}/delegates
Add a new delegate (proposer) to a Safe.
**Request Body** (`CreateDelegateDto`):
```typescript
{
safe?: string | null // The Safe address the delegate can propose to
delegate: string // Address being granted proposer rights
delegator: string // Address authorizing (EOA or Safe address)
signature: string // Authorization signature (EIP-712 EOA or EIP-1271 contract)
label: string // Human-readable name
}
```
**For nested Safe owner flow**:
- `safe`: The nested Safe address
- `delegate`: The new proposer address
- `delegator`: The parent Safe address (not the EOA wallet)
- `signature`: EIP-1271 contract signature from the parent Safe
- `label`: User-provided name
**Response**: `201 Created`
### DELETE /v2/chains/{chainId}/delegates/{delegateAddress}
Remove a delegate from a Safe.
**Request Body** (`DeleteDelegateV2Dto`):
```typescript
{
delegator?: string | null // Address that authorized (for permission check)
safe?: string | null // The Safe address
signature: string // Authorization signature
}
```
**Response**: `204 No Content`
### GET /v2/chains/{chainId}/delegates
List delegates for a Safe.
**Query Parameters**:
- `safe`: Filter by Safe address
- `delegate`: Filter by delegate address
- `delegator`: Filter by delegator address
**Response** (`DelegatePage`):
```typescript
{
count?: number | null
next?: string | null
previous?: string | null
results: Array<{
safe?: string | null
delegate: string
delegator: string
label: string
}>
}
```
## Signature Formats
### EOA Signature (existing, direct owners)
Standard EIP-712 signature from the delegator's private key:
- 65 bytes: `r (32) + s (32) + v (1)`
- Produced by `eth_signTypedData_v4`
### Contract Signature (new, nested Safe owners)
EIP-1271 contract signature from the parent Safe:
- Variable length, encodes the verifying contract address
- Parent Safe must have signed the delegate typed data hash on-chain via SignMessageLib
- Backend validates by calling `isValidSignature(hash, signature)` on the delegator (parent Safe) contract
## Typed Data Structure
Both EOA and contract signatures authorize the same typed data:
```typescript
{
domain: {
name: "Safe Transaction Service",
version: "1.0",
chainId: <chain ID>
},
types: {
Delegate: [
{ name: "delegateAddress", type: "address" },
{ name: "totp", type: "uint256" }
]
},
message: {
delegateAddress: <proposer address>,
totp: Math.floor(Date.now() / 1000 / 3600) // hourly window
},
primaryType: "Delegate"
}
```
## Backend Support (Confirmed)
The Safe Transaction Service FULLY supports EIP-1271 contract signature validation for the delegate API.
**Verified in source code**:
- `/workspace/safe-transaction-service/safe_transaction_service/history/serializers.py``DelegateSerializerV2.validate_delegator_signature()` uses `SafeSignature.parse_signature()` which detects contract signatures (v=0) and calls `is_valid(ethereum_client, owner)` which invokes `isValidSignature` on-chain.
- `/workspace/safe-transaction-service/safe_transaction_service/history/tests/test_views_v2.py` — Explicit test `_test_add_delegate_using_1271_signature()` verifies a nested Safe (contract) as delegator returns HTTP 201 Created.
**CGW behavior** (confirmed in `/workspace/safe-client-gateway`): The CGW is a pure proxy for delegate operations — it validates only the request schema (addresses, required fields) and forwards the signature as-is to the Transaction Service. No signature validation occurs in the CGW.
**Validation flow**:
1. CGW receives POST with `delegator: parentSafeAddress` and `signature: eip1271Bytes`
2. CGW forwards to Transaction Service at `POST {transactionService}/api/v2/delegates/`
3. Transaction Service parses signature, detects v=0 (contract signature)
4. Extracts parent Safe address from r-value
5. Calls `isValidSignature(delegateTypedDataHash, signatureData)` on the parent Safe contract
6. Parent Safe validates inner owner signatures against its threshold
7. If valid, returns EIP-1271 magic value → delegation accepted (HTTP 201)
@@ -0,0 +1,160 @@
# Data Model: Nested Safe Proposer Management
**Date**: 2026-01-23
**Feature**: 001-nested-safe-proposer
## Entities
### Safe Account
Represents a multi-signature wallet contract on an EVM chain.
| Field | Type | Description |
| --------- | --------- | ----------------------------------------- |
| address | Address | The Safe's contract address |
| chainId | string | The chain where this Safe is deployed |
| owners | Address[] | List of addresses authorized as signers |
| threshold | number | Minimum signatures required for execution |
| deployed | boolean | Whether the Safe contract exists on-chain |
| version | string | Safe contract version (e.g., "1.3.0") |
### Delegate (Proposer)
Represents a delegation granting proposal rights to an address.
| Field | Type | Description |
| --------- | --------------- | -------------------------------------------------------- |
| delegate | Address | The address granted proposer rights |
| delegator | Address | The address that authorized the delegation (EOA or Safe) |
| safe | Address \| null | The Safe this delegation applies to |
| label | string | Human-readable name for the proposer |
| signature | HexString | The authorization signature (EIP-712 or EIP-1271) |
### Nested Safe Ownership Relationship
Represents the ownership chain between a user's wallet and a nested Safe.
| Field | Type | Description |
| ---------- | ------- | ------------------------------------------------------------- |
| userWallet | Address | The connected EOA wallet |
| parentSafe | Address | The Safe controlled by the user (one of nested Safe's owners) |
| nestedSafe | Address | The target Safe where the proposer is being added |
| chainId | string | The chain (must be same for all three) |
## Relationships
```
UserWallet ──owns──> ParentSafe ──owns──> NestedSafe
└── delegator for ──> Delegate (Proposer)
└── can propose on ──> NestedSafe
```
## State Transitions
### Delegation Creation (Nested Safe Owner Flow — 1-of-1 parent Safe)
```
States:
IDLE → FORM_OPEN → SIGNING → DELEGATION_SUBMITTED → COMPLETE
Transitions:
IDLE → FORM_OPEN
Trigger: User clicks "Add proposer" button
Condition: User is nested Safe owner, Safe is deployed
FORM_OPEN → SIGNING
Trigger: User submits form with valid proposer address and label
Action: Sign delegate typed data with connected wallet (EOA)
Condition: Address validation passes
SIGNING → DELEGATION_SUBMITTED
Trigger: EOA signature obtained
Action: Wrap signature in EIP-1271 format (v=0, r=parentSafe, s=65, + ABI-encoded signature)
POST to delegate API with delegator=parentSafeAddress
Condition: Signature valid
DELEGATION_SUBMITTED → COMPLETE
Trigger: API accepts the delegation (HTTP 201)
Action: Cache invalidation, proposer appears in list
```
Note: For multi-sig parent Safes (threshold > 1), additional states would be needed to collect
multiple owner signatures before wrapping in EIP-1271 format. This is deferred to a future phase.
### Delegation Creation (Direct Owner Flow - existing, unchanged)
```
States:
IDLE → FORM_OPEN → SIGNING → DELEGATION_SUBMITTED → COMPLETE
Transitions:
IDLE → FORM_OPEN
Trigger: User clicks "Add proposer" button
FORM_OPEN → SIGNING
Trigger: User submits form
Action: Wallet signs EIP-712 typed data directly
SIGNING → DELEGATION_SUBMITTED
Trigger: Signature obtained
Action: POST to delegate API with EOA signature
DELEGATION_SUBMITTED → COMPLETE
Trigger: API accepts
Action: Cache invalidation
```
## Validation Rules
| Rule | Entity | Constraint |
| ----------------------- | --------- | ----------------------------------------------- |
| Not self-delegation | Delegate | delegate address != Safe address |
| Not existing owner | Delegate | delegate address not in Safe.owners |
| Valid Ethereum address | Delegate | delegate must be a valid checksummed address |
| Safe must be deployed | Safe | deployed == true for proposer management |
| Chain consistency | All | parentSafe.chainId == nestedSafe.chainId |
| Nested ownership exists | Ownership | parentSafe.address must be in nestedSafe.owners |
| User controls parent | Ownership | userWallet must be in parentSafe.owners |
## Key Data Flows
### Permission Check Data Flow
```
useOwnedSafes(wallet.address) → owned Safe addresses on current chain
useSafeInfo().safe.owners → current Safe's owner addresses
intersection(owned, owners) → parent Safes the user controls
length > 0 → isNestedSafeOwner = true
```
### Delegate Typed Data (for signature)
```
Domain: { name: "Safe Transaction Service", version: "1.0", chainId }
Types: { Delegate: [{ delegateAddress: address }, { totp: uint256 }] }
Message: { delegateAddress: <proposer>, totp: floor(now / 3600) }
```
### EIP-1271 Signature Construction (inline owner signatures)
```
signature = concat(
r: parentSafe.address (32 bytes, left-padded with zeros),
s: 0x41 (32 bytes, = 65, offset to dynamic signature data),
v: 0x00 (1 byte, indicates contract signature type),
--- dynamic data starts at byte 65 ---
ABI-encoded bytes of concatenated owner signatures
)
```
For a 1-of-1 parent Safe with a single EOA owner:
```
bytes 0-31: parentSafe address (left-padded to 32 bytes)
bytes 32-63: 0x0000...0041 (offset = 65)
byte 64: 0x00 (v = contract signature)
bytes 65+: abi.encode(["bytes"], [ownerEOASignature])[32:]
(length-prefixed owner signature, 65 bytes for ECDSA)
```
+171
View File
@@ -0,0 +1,171 @@
# Implementation Plan: Nested Safe Proposer Management
**Branch**: `001-nested-safe-proposer` | **Date**: 2026-01-23 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/001-nested-safe-proposer/spec.md`
## Summary
Enable users who are nested Safe owners (their wallet controls a parent Safe that is an owner of the target Safe) to add proposers to the nested Safe. This requires two changes: (1) replace the `OnlyOwner` permission gate with `CheckWallet` on the "Add proposer" button, and (2) implement a signing flow that wraps the connected wallet's EOA signature in EIP-1271 format with the parent Safe as delegator. Backend EIP-1271 support has been confirmed in the Safe Transaction Service source code.
## Technical Context
**Language/Version**: TypeScript 5.x (Next.js 14.x)
**Primary Dependencies**: React, MUI, Redux Toolkit (RTK Query), ethers.js, @safe-global/protocol-kit, @safe-global/api-kit
**Storage**: N/A (backend-managed via CGW API)
**Testing**: Jest + React Testing Library + MSW
**Target Platform**: Web (Next.js, all modern browsers)
**Project Type**: Web (monorepo workspace `apps/web`)
**Performance Goals**: Standard web app responsiveness (<100ms UI interactions)
**Constraints**: Must work with existing CGW delegate API; parent Safe signing is async (multisig threshold)
**Scale/Scope**: ~5-7 files modified, 1-2 new utility functions
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
| Principle | Status | Notes |
| ------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| I. Type Safety | PASS | All new code will use proper TypeScript interfaces. No `any` types. |
| II. Branch Protection | PASS | Working on feature branch `001-nested-safe-proposer`. Will run all quality gates before commit. |
| III. Cross-Platform Consistency | PASS | Changes are web-only (`apps/web/`). No shared package modifications. |
| IV. Testing Discipline | PASS | Will use MSW for API mocking, colocated test files, faker for test data. |
| V. Feature Organization | PASS | Changes are within existing `src/features/proposers/` and `src/components/settings/`. No new feature folder needed (extending existing). Existing feature flag (`FEATURES.PROPOSERS`) applies. |
| VI. Theme System Integrity | PASS | No styling changes required. |
**Post-Phase 1 Re-check**: All gates still pass. No new patterns or dependencies introduced that violate constitution.
## Project Structure
### Documentation (this feature)
```text
specs/001-nested-safe-proposer/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
│ └── delegate-api.md # API contract documentation
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
apps/web/src/
├── components/
│ ├── common/
│ │ ├── CheckWallet/index.tsx # Already supports nested owners (no changes)
│ │ └── OnlyOwner/index.tsx # Reference only (being replaced in ProposersList)
│ └── settings/
│ └── ProposersList/index.tsx # MODIFY: Replace OnlyOwner with CheckWallet
├── features/
│ └── proposers/
│ ├── components/
│ │ └── UpsertProposer.tsx # MODIFY: Add nested Safe owner detection + EIP-1271 wrapping
│ └── utils/
│ └── utils.ts # MODIFY: Add encodeEIP1271Signature() helper
└── hooks/
├── useIsNestedSafeOwner.ts # Existing (no changes)
└── useNestedSafeOwners.tsx # Existing (no changes)
```
**Structure Decision**: This feature extends the existing proposers feature within `apps/web/`. No new directories or feature folders are needed. The changes are localized to the permission gate component and the proposer form submission logic.
## Implementation Phases
### Phase A: Permission Gate Fix (P1)
**Goal**: Enable the "Add proposer" button for nested Safe owners.
**Change**: In `ProposersList/index.tsx`, replace:
```tsx
<OnlyOwner>
{(isOk) => ( ... )}
</OnlyOwner>
```
with:
```tsx
<CheckWallet allowProposer={false}>
{(isOk) => ( ... )}
</CheckWallet>
```
**Why `allowProposer={false}`**: Proposers should not be able to add other proposers. Only direct owners and nested Safe owners should manage proposers.
**Risk**: Low. `CheckWallet` is a well-tested component already used throughout the app.
**Tests**:
- Unit test: Verify button enabled when `useIsNestedSafeOwner` returns true
- Unit test: Verify button disabled when user is only a proposer (not owner/nested owner)
- Unit test: Verify button disabled when user has no relationship to the Safe
- Unit test: Verify existing direct owner behavior unchanged
### Phase B: Nested Safe Signing Flow (P2)
**Goal**: When a nested Safe owner submits the proposer form, sign with the connected wallet and wrap in EIP-1271 format with the parent Safe as delegator.
**Confirmed**: The Safe Transaction Service fully supports EIP-1271 contract signatures for delegates (verified in source code and explicit test `_test_add_delegate_using_1271_signature()`). No on-chain transaction (SignMessageLib) is required.
**Steps**:
1. **Detect nested Safe owner in UpsertProposer**: Use `useIsNestedSafeOwner()` and `useNestedSafeOwners()` to determine if the current user is a nested Safe owner and get the parent Safe address.
2. **Sign delegate typed data with connected wallet**: Use the same `signProposerTypedData()` or `signProposerData()` functions. The connected wallet IS an owner of the parent Safe, so its signature is valid as an inner signature.
3. **Wrap in EIP-1271 format**: Encode the EOA signature in the EIP-1271 contract signature format:
```
v=0, r=parentSafeAddress (32 bytes), s=65 (offset to dynamic data)
+ ABI-encoded bytes of the owner signature(s)
```
4. **Submit to delegate API**: POST with `delegator: parentSafeAddress` and `signature: eip1271Signature`.
**Flow for 1-of-1 parent Safe** (initial scope):
- Single-step, synchronous — identical UX to direct owner flow
- Connected wallet signs → wrap in EIP-1271 → submit → done
**Flow for multi-sig parent Safe** (threshold > 1, future phase):
- Would require collecting signatures from additional parent Safe owners
- Deferred — out of scope for initial implementation
**Risk**: Low-Medium.
- Backend EIP-1271 support: CONFIRMED (no longer a risk)
- EIP-1271 encoding: Well-defined format, test reference available
- Scope limited to 1-of-1 parent Safes initially
**New utility functions needed** (in `apps/web/src/features/proposers/utils/utils.ts`):
- `encodeEIP1271Signature(parentSafeAddress: string, ownerSignature: string): string` — wraps an EOA signature in EIP-1271 contract signature format
- Update `UpsertProposer` to detect nested Safe ownership and use the new encoding
**Tests**:
- Unit test: `encodeEIP1271Signature` produces correct byte layout
- Unit test: Parent Safe address is correctly identified from `useNestedSafeOwners()`
- Unit test: UpsertProposer uses EIP-1271 flow when user is nested Safe owner
- Unit test: UpsertProposer uses direct EOA flow when user is direct owner (no regression)
- Integration test: Full submission flow with mocked delegate API (MSW)
## Dependencies & Risks
| Dependency | Risk | Status | Notes |
| -------------------------------------- | ------ | --------- | --------------------------------------------------------------------------------------------------------------- |
| Backend EIP-1271 support for delegates | LOW | CONFIRMED | Safe Transaction Service supports it; explicit test exists (`_test_add_delegate_using_1271_signature`) |
| EIP-1271 signature encoding | LOW | RESOLVED | Format documented in Transaction Service test; well-defined byte layout |
| Parent Safe threshold > 1 | MEDIUM | DEFERRED | Initial scope limited to 1-of-1 parent Safes. Multi-sig collection is a future phase. |
| TOTP expiration | LOW | RESOLVED | Backend accepts current AND previous hour's TOTP (~2 hour window). For 1-of-1 parent Safes, signing is instant. |
## Open Questions (resolved)
1. ~~**Backend EIP-1271 support**~~: CONFIRMED. The Safe Transaction Service `DelegateSerializerV2.validate_delegator_signature()` calls `safe_signature.is_valid(ethereum_client, owner)` which invokes `isValidSignature` on contract delegators.
2. **Multi-signer UX** (deferred): For threshold > 1 parent Safes, a signature collection mechanism is needed. This is out of scope for the initial implementation.
3. ~~**TOTP window**~~: RESOLVED. The backend tries both current and previous TOTP values, giving a ~2 hour validity window. For the synchronous 1-of-1 flow, this is not a concern.
@@ -0,0 +1,85 @@
# Quickstart: Nested Safe Proposer Management
**Date**: 2026-01-23
**Feature**: 001-nested-safe-proposer
## Prerequisites
- Node.js (version per `.nvmrc`)
- Yarn 4 (via corepack)
- Repository cloned and dependencies installed: `yarn install`
## Development Setup
```bash
# Checkout feature branch
git checkout 001-nested-safe-proposer
# Install dependencies
yarn install
# Run web app in development mode
yarn workspace @safe-global/web dev
```
## Key Files to Modify
### 1. Permission Gate (P1 - Button enablement)
**File**: `apps/web/src/components/settings/ProposersList/index.tsx`
- Replace `OnlyOwner` wrapper with `CheckWallet` component
- Configure: `allowProposer={false}` (nested owners yes, proposers no)
**File**: `apps/web/src/components/common/CheckWallet/index.tsx`
- No changes needed — already supports `isNestedSafeOwner`
### 2. Proposer Form (P2 - Signing flow)
**File**: `apps/web/src/features/proposers/components/UpsertProposer.tsx`
- Add conditional logic: if user is nested Safe owner, wrap EOA signature in EIP-1271 format
- Determine parent Safe address from `useNestedSafeOwners()`
- Use parent Safe address as `delegator` in the API call
### 3. Signing Utilities
**File**: `apps/web/src/features/proposers/utils/utils.ts`
- Add `encodeEIP1271Signature(parentSafeAddress: string, ownerSignature: string): string`
- Encodes: v=0, r=parentSafeAddress (32 bytes), s=65, + ABI-encoded owner signature
## Testing
```bash
# Run unit tests
yarn workspace @safe-global/web test
# Run specific test file
yarn workspace @safe-global/web test --testPathPattern="ProposersList"
# Type checking
yarn workspace @safe-global/web type-check
# Linting
yarn workspace @safe-global/web lint
```
## Test Scenarios
1. **Nested Safe owner sees enabled button**: Connect wallet → Open nested Safe → Settings > Setup → Verify "Add proposer" enabled
2. **Non-owner sees disabled button**: Connect unrelated wallet → Same page → Verify disabled with tooltip
3. **Direct owner flow unchanged**: Connect direct owner → Add proposer → Verify existing flow works
4. **Nested Safe owner initiates proposer addition**: Click "Add proposer" → Fill form → Submit → Verify parent Safe tx created
## Architecture Notes
- The permission fix (replacing `OnlyOwner` with `CheckWallet`) is a simple, low-risk change
- The signing flow is simpler than initially expected:
- No on-chain transaction (SignMessageLib) needed
- No async multisig approval needed (for 1-of-1 parent Safes)
- Backend EIP-1271 support confirmed in Safe Transaction Service source code
- The connected wallet signs the delegate typed data (same as direct owner), then the signature is wrapped in EIP-1271 format
- For multi-sig parent Safes (threshold > 1), collecting multiple owner signatures is needed — deferred to future phase
- Both phases can be implemented together since there are no blocking dependencies
+160
View File
@@ -0,0 +1,160 @@
# Research: Nested Safe Proposer Management
**Date**: 2026-01-23
**Feature**: 001-nested-safe-proposer
## Research Questions & Findings
### RQ-001: How does the current permission check work for the "Add proposer" button?
**Decision**: The ProposersList component uses `OnlyOwner` wrapper, which only checks direct Safe ownership via `useIsSafeOwner()`. This excludes nested Safe owners.
**Rationale**: The `OnlyOwner` component is a strict permission gate that only validates if the connected wallet address exists in `safe.owners`. It does not support nested Safe owners, proposers, or any other authorization level.
**Alternatives considered**:
- `CheckWallet` component supports nested Safe owners via `useIsNestedSafeOwner()` and has configurable props (`allowProposer`, `allowNonOwner`, etc.)
- The fix for the button enablement is to replace `OnlyOwner` with `CheckWallet` using `allowProposer={false}` to allow nested Safe owners but not proposers.
### RQ-002: How does the delegate/proposer API handle delegator identity and signatures?
**Decision**: The CGW delegate API V2 accepts a `CreateDelegateDto` with fields: `safe`, `delegate`, `delegator`, `signature`, `label`. The `delegator` is the address that authorizes the delegation, and the `signature` must be verifiable against the `delegator` address.
**Rationale**: The API uses EIP-712 typed data with domain "Safe Transaction Service" containing `delegateAddress` and `totp` (hourly time-based value). The backend validates that the signature was produced by the `delegator` address.
**Key finding**: The API types do not explicitly distinguish between EOA and contract signatures. The `delegator` field accepts any address (EOA or Safe). The signature format is a standard hex string.
### RQ-003: Can a Safe (smart contract) produce a valid delegation signature?
**Decision**: YES — confirmed. The Safe Transaction Service fully supports EIP-1271 contract signatures for the delegate API. No on-chain SignMessageLib transaction is required. Instead, the parent Safe's owner signatures are encoded inline in EIP-1271 format.
**Confirmed by**: Safe Transaction Service source code at `/workspace/safe-transaction-service/safe_transaction_service/history/serializers.py` (class `DelegateSerializerV2`, method `validate_delegator_signature`) and explicit test `_test_add_delegate_using_1271_signature()` in `/workspace/safe-transaction-service/safe_transaction_service/history/tests/test_views_v2.py`.
**How it works**:
1. The Safe Transaction Service calls `SafeSignature.parse_signature(signature, message_hash)` which detects the signature type from the v-value.
2. For v=0 (contract signature), it extracts the contract address from the r-value.
3. It calls `safe_signature.is_valid(ethereum_client, owner)` which invokes `isValidSignature(hash, data)` on the parent Safe contract.
4. The Safe contract's `isValidSignature` implementation validates the inline owner signatures against its threshold.
**EIP-1271 signature format for delegates** (from test):
```
signature_1271 = (
signature_to_bytes(v=0, r=int(parent_safe_address), s=65) # 65-byte header
+ eth_abi.encode(["bytes"], [concatenated_owner_signatures])[32:] # dynamic data
)
```
Structure:
- Bytes 0-31 (r): Parent Safe address, left-padded to 32 bytes
- Bytes 32-63 (s): Offset to dynamic signature data (value: 65)
- Byte 64 (v): 0x00 (indicates contract signature)
- Bytes 65+: ABI-encoded bytes containing concatenated owner signatures
**Rationale**: The backend validates by calling `isValidSignature` on the parent Safe. The Safe contract checks whether the provided inner signatures meet its threshold. This means:
- For a 1-of-1 parent Safe: Only the connected wallet's signature is needed (single-step flow)
- For a multi-sig parent Safe (threshold > 1): Multiple owner signatures must be collected before submission
**Alternatives considered**:
- SignMessageLib on-chain signing (rejected: not needed — inline EIP-1271 signatures are simpler and don't require on-chain transactions)
- Direct EOA signing as delegator (rejected: user specified parent Safe as delegator)
**CGW behavior** (confirmed at `/workspace/safe-client-gateway`): The CGW does NOT validate signatures — it simply proxies the request to the Transaction Service. The `signature` field is validated only as a string format (`z.string()` in the schema).
### RQ-004: What is the existing pattern for Safe-as-signer in the app?
**Decision**: The app uses `dispatchOnChainSigning` for contract wallet signers in transaction flows. However, for delegate registration (an off-chain API call), we use a different pattern: EIP-1271 inline signatures where the parent Safe's owners sign off-chain and their signatures are wrapped in EIP-1271 format.
**Rationale**: The on-chain `approveHash` pattern exists for Safe transactions, but delegate registration is an HTTP POST to the CGW API, not an on-chain transaction. The backend validates EIP-1271 signatures by calling `isValidSignature` on the delegator contract, which checks inline owner signatures against the threshold.
**Key insight**: For a 1-of-1 parent Safe, the connected wallet (sole owner) signs the delegate typed data directly, then the signature is wrapped in EIP-1271 format with the parent Safe address. No on-chain transaction or async flow needed.
### RQ-005: How does EIP-1271 validation work for the delegate API?
**Decision**: The Safe Transaction Service validates EIP-1271 delegate signatures by calling `isValidSignature(hash, signature_data)` on the delegator contract (parent Safe). The Safe contract validates the inline owner signatures against its threshold. No SignMessageLib on-chain transaction is required.
**Confirmed by**: The `validate_delegator_signature()` method in `/workspace/safe-transaction-service/safe_transaction_service/history/serializers.py` (lines 449-489):
1. Computes the delegate typed data hash (with TOTP and chain_id)
2. Parses the signature via `SafeSignature.parse_signature(signature, message_hash)`
3. For EIP-1271 (v=0): extracts the contract address from r-value
4. Calls `safe_signature.is_valid(ethereum_client, owner)` which invokes `isValidSignature` on-chain
5. The Safe contract checks if the inline signatures meet its threshold
**TOTP handling**: The backend tries 4 combinations to be lenient:
- Current TOTP vs previous TOTP (allows signatures from the previous hour)
- Current chain_id vs None (backwards compatibility)
- This gives a ~2-hour validity window for signatures
**Implication for implementation**: The flow for nested Safe proposer addition is:
1. Compute the delegate typed data hash (same as for EOA, using `getDelegateTypedData()`)
2. Have the parent Safe's owners sign this hash off-chain (each signs with their EOA)
3. For a 1-of-1 parent Safe: the connected wallet is the sole owner, so only one signature needed
4. Encode the owner signature(s) in EIP-1271 format (v=0, r=parentSafe, s=65, then ABI-encoded signatures)
5. POST to delegate API with `delegator: parentSafeAddress`, `signature: eip1271Signature`
**Key simplification**: No on-chain transaction is needed. No SignMessageLib. No async multisig approval wait. For a 1-of-1 parent Safe, this is a single-step synchronous flow (sign → wrap → submit), identical in UX to the direct owner flow.
**Multi-sig parent Safe (threshold > 1)**: Would require collecting signatures from multiple owners before submission. This is a UX challenge but not a backend limitation. Could be deferred to a later phase.
### RQ-006: How does the nested Safe owner detection work?
**Decision**: `useNestedSafeOwners()` finds the intersection of Safes owned by the connected wallet AND listed as owners of the current Safe. `useIsNestedSafeOwner()` returns boolean based on this.
**Rationale**:
1. `useOwnedSafes()` fetches all Safes where the wallet is a signer (via CGW API)
2. `safe.owners` provides the current Safe's owner list
3. Intersection = Safes the user controls that are owners of the current Safe
4. This is limited to one level of nesting (direct parent only)
### RQ-007: What is the UpsertProposer submission flow?
**Decision**: The current `UpsertProposer` component:
1. Validates address (not self, not existing owner)
2. Detects wallet type (ETH_SIGN vs EIP-712)
3. Signs with connected wallet EOA via `signProposerTypedData()` or `signProposerData()`
4. POSTs to delegate API with `delegator: wallet.address`
**Modification needed**: For nested Safe owners, the component needs an alternate flow:
1. Same validation (address not self, not existing owner)
2. Determine parent Safe address from `useNestedSafeOwners()`
3. Sign the delegate typed data with the connected wallet (same as direct owner — the wallet IS an owner of the parent Safe)
4. Wrap the EOA signature in EIP-1271 format: `v=0, r=parentSafeAddress, s=65, + ABI-encoded(ownerSignature)`
5. POST to delegate API with `delegator: parentSafeAddress`, `signature: eip1271Signature`
For a 1-of-1 parent Safe, this is a single-step synchronous flow. For multi-sig parent Safes (threshold > 1), collecting additional owner signatures is needed (deferred to future phase).
## Architecture Decision
The implementation requires two distinct changes:
1. **Permission gate fix** (simple): Replace `OnlyOwner` with `CheckWallet` in ProposersList to enable the button for nested Safe owners.
2. **Signing flow** (moderate complexity): Create a new code path in UpsertProposer that, for nested Safe owners:
- Identifies the parent Safe address (from `useNestedSafeOwners()`)
- Signs the delegate typed data with the connected wallet's EOA (same signing method as direct owners)
- Wraps the EOA signature in EIP-1271 format (v=0, r=parentSafe, s=65, + ABI-encoded signature)
- POSTs to delegate API with `delegator: parentSafeAddress`
**Key simplification** (confirmed via Safe Transaction Service source): No on-chain transaction is needed. The backend calls `isValidSignature` on the parent Safe contract, which validates inline owner signatures. For a 1-of-1 parent Safe, this is a single-step synchronous flow — the UX is identical to the direct owner flow.
**Scope limitation**: Multi-sig parent Safes (threshold > 1) would require collecting signatures from multiple owners before submission. This is deferred to a future phase. The initial implementation targets 1-of-1 parent Safes (or cases where the connected wallet's single signature meets the threshold).
## Backend Verification Sources
| Source | Location | Finding |
| ------------------------------ | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
| CGW delegate handler | `/workspace/safe-client-gateway/src/modules/delegate/` | Proxy only — no signature validation, passes to Transaction Service |
| Transaction Service serializer | `/workspace/safe-transaction-service/.../history/serializers.py` | `DelegateSerializerV2.validate_delegator_signature()` supports EIP-1271 |
| Transaction Service test | `/workspace/safe-transaction-service/.../tests/test_views_v2.py` | `_test_add_delegate_using_1271_signature()` — explicit test with nested Safe as delegator |
| Signature parsing | `safe_eth` library via `SafeSignature.parse_signature()` | Detects v=0 as contract signature, extracts address from r-value |
| Validation | `safe_signature.is_valid(ethereum_client, owner)` | Calls `isValidSignature` on-chain for contract signatures |
+106
View File
@@ -0,0 +1,106 @@
# Feature Specification: Nested Safe Proposer Management
**Feature Branch**: `001-nested-safe-proposer`
**Created**: 2026-01-23
**Status**: Draft
**Input**: User description: "As a user with a Safe and a nested safe, I want to be able to add a proposer to the nested safe. Currently when connected to my nested safe and on the settings page the add a proposer button is disabled with the error message 'Your connected wallet is not a signer of this Safe Account'"
## Clarifications
### Session 2026-01-23
- Q: Who is the delegator when a nested Safe owner adds a proposer — the connected wallet (EOA) or the parent Safe? → A: The parent Safe address is the delegator. This requires the parent Safe to produce the delegation signature via a multisig approval flow.
## User Scenarios & Testing _(mandatory)_
### User Story 1 - Add Proposer to Nested Safe via Parent Safe Ownership (Priority: P1)
As a user whose connected wallet controls a parent Safe that is an owner of a nested Safe, I want to add a proposer to the nested Safe so that I can delegate transaction proposal rights without being a direct signer of the nested Safe.
Currently, when I navigate to the nested Safe's Settings > Setup page, the "Add proposer" button is disabled with the tooltip "Your connected wallet is not a signer of this Safe Account." This occurs because the permission check only verifies direct ownership, not ownership through a parent Safe in the hierarchy.
**Why this priority**: This is the core bug/feature gap — users who legitimately control a nested Safe through a parent Safe are blocked from managing proposers, which breaks expected functionality.
**Independent Test**: Can be fully tested by connecting a wallet that owns a parent Safe (which is an owner of the nested Safe), navigating to the nested Safe's settings, and verifying the "Add proposer" button is enabled and functional.
**Acceptance Scenarios**:
1. **Given** a user whose wallet is a signer of Safe A, and Safe A is an owner of Safe B (nested Safe), **When** the user navigates to Safe B's Settings > Setup page, **Then** the "Add proposer" button is enabled and clickable.
2. **Given** a user whose wallet is a signer of Safe A, and Safe A is an owner of Safe B, **When** the user clicks "Add proposer" on Safe B's settings, **Then** the proposer creation dialog opens and functions correctly.
3. **Given** a user whose wallet is a signer of Safe A, and Safe A is an owner of Safe B, **When** the user submits a new proposer for Safe B, **Then** the proposer is successfully added and appears in the proposers list.
---
### User Story 2 - Correct Permission Feedback for Non-Owners (Priority: P2)
As a user who is neither a direct signer nor a nested Safe owner, I want to see the appropriate disabled state and error message so that I understand why I cannot add a proposer.
**Why this priority**: Ensures that the permission relaxation for nested Safe owners does not inadvertently allow unauthorized users to manage proposers.
**Independent Test**: Can be tested by connecting a wallet that is neither a direct owner nor a nested Safe owner of the target Safe, and verifying the button remains disabled with the correct message.
**Acceptance Scenarios**:
1. **Given** a user whose wallet is not a signer of the Safe and does not control any parent Safe that owns it, **When** the user views the Settings > Setup page, **Then** the "Add proposer" button remains disabled with the message "Your connected wallet is not a signer of this Safe Account."
2. **Given** a user who is only a proposer (not an owner or nested Safe owner), **When** the user views the Settings > Setup page, **Then** the "Add proposer" button remains disabled.
---
### User Story 3 - Signing Flow for Nested Safe Proposer Addition (Priority: P2)
As a nested Safe owner adding a proposer, I want the delegation to be authorized by the parent Safe (as the delegator) so that the proposer is properly registered under the parent Safe's authority.
Since the parent Safe is the delegator (not the connected EOA wallet), adding a proposer from a nested Safe requires a multisig approval flow on the parent Safe to produce the delegation signature. The user initiates the proposer addition from the nested Safe's settings, but the authorization is routed through the parent Safe.
**Why this priority**: The proposer delegation must be signed by the delegator (parent Safe). This requires orchestrating a multisig transaction on the parent Safe, which is more complex than a direct EOA signature.
**Independent Test**: Can be tested by initiating a proposer addition on the nested Safe, confirming it creates a signing request on the parent Safe, completing the multisig approval, and verifying the proposer appears in the nested Safe's list.
**Acceptance Scenarios**:
1. **Given** a nested Safe owner has entered a valid proposer address and label on the nested Safe's settings, **When** they submit the form, **Then** a signing/approval request is created on the parent Safe for the delegation.
2. **Given** the parent Safe's required threshold of signers have approved the delegation, **When** the delegation is submitted to the backend, **Then** it is accepted and the proposer appears in the nested Safe's proposers list.
3. **Given** a nested Safe owner initiates a proposer addition but the parent Safe's threshold is not yet met, **When** they view the status, **Then** they can see the pending approval state.
---
### Edge Cases
- What happens when the user's wallet controls multiple parent Safes that are owners of the nested Safe? The user should still be able to add a proposer (any valid nested ownership path is sufficient).
- What happens when the nested Safe is undeployed (counterfactual)? The "Add proposer" button should remain disabled with the existing "activate Safe" message, regardless of nested ownership.
- What happens when the parent Safe is removed as an owner of the nested Safe while the user is on the settings page? The button should reflect the updated ownership state on next data refresh.
- What happens if the nested ownership chain is deeper than one level (Safe A owns Safe B, which owns Safe C)? Only direct parent Safe ownership should be considered (one level deep), consistent with existing nested Safe owner detection behavior.
## Requirements _(mandatory)_
### Functional Requirements
- **FR-001**: System MUST recognize a user as authorized to manage proposers on a Safe if their connected wallet is a signer of any Safe that is a direct owner of the target Safe (nested Safe ownership).
- **FR-002**: System MUST enable the "Add proposer" button for users who are nested Safe owners, provided the Safe is deployed.
- **FR-003**: System MUST continue to disable the "Add proposer" button for users who are neither direct signers nor nested Safe owners.
- **FR-004**: System MUST allow nested Safe owners to initiate a proposer addition (enter address, provide label) and route the delegation signature through the parent Safe's multisig approval flow, using the parent Safe as the delegator.
- **FR-005**: System MUST maintain the existing disabled state and tooltip message for undeployed Safes, regardless of ownership type.
- **FR-006**: System MUST validate that proposed addresses are not the Safe itself and not existing owners, regardless of whether the requester is a direct signer or nested Safe owner.
### Key Entities
- **Safe Account (Parent)**: A multi-signature wallet whose signers include the connected user's wallet. Acts as an owner of the nested Safe.
- **Safe Account (Nested/Child)**: A multi-signature wallet that has another Safe Account as one of its owners. This is the Safe where the proposer is being added.
- **Proposer**: An address delegated permission to suggest transactions to a Safe, without approval or execution rights.
- **Nested Safe Owner**: A user whose connected wallet is a signer of a Safe that is itself an owner of the target Safe.
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Users who are nested Safe owners can successfully add a proposer to the nested Safe within 60 seconds of navigating to the settings page.
- **SC-002**: 100% of proposer additions by nested Safe owners result in the proposer appearing in the proposers list after page refresh.
- **SC-003**: The "Add proposer" button correctly reflects permissions for all ownership scenarios (direct owner, nested owner, non-owner) with zero false positives or false negatives.
- **SC-004**: No regression in existing proposer management functionality for direct Safe owners.
## Assumptions
- The existing nested Safe owner detection correctly identifies whether the connected wallet controls a parent Safe that owns the current Safe. This behavior is already implemented and used elsewhere in the app.
- The proposer delegation signing flow for nested Safe owners uses the parent Safe as the delegator, requiring multisig approval on the parent Safe to produce the delegation signature. The backend delegation API accepts Safe contract signatures (not just EOA signatures) as valid delegator authorization.
- Nested Safe ownership detection is limited to one level of nesting (the connected wallet's Safe is a direct owner of the target Safe), consistent with existing behavior.
- The feature flag for proposers is already enabled on the relevant chains and does not need modification.
+143
View File
@@ -0,0 +1,143 @@
# Tasks: Nested Safe Proposer Management
**Input**: Design documents from `/specs/001-nested-safe-proposer/`
**Prerequisites**: plan.md (required), spec.md (required), research.md, data-model.md, contracts/
**Tests**: Included — the plan explicitly defines unit tests for each phase.
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3)
- Include exact file paths in descriptions
## Phase 1: Foundational (EIP-1271 Utility)
**Purpose**: Create the shared EIP-1271 signature encoding utility that the signing flow depends on.
**Why foundational**: The `encodeEIP1271Signature` function is needed by User Story 1 (full flow) and User Story 3 (signing). It operates on a separate file from the permission gate change, so it can be implemented first without conflicts.
- [x] T001 Implement `encodeEIP1271Signature(parentSafeAddress: string, ownerSignature: string): string` utility function in `apps/web/src/features/proposers/utils/utils.ts`. The function must encode the signature in EIP-1271 contract signature format: r=parentSafeAddress (32 bytes, left-padded), s=0x41 (65, offset to dynamic data), v=0x00, followed by ABI-encoded bytes of the owner signature. Reference format from Safe Transaction Service test `_test_add_delegate_using_1271_signature`.
- [x] T002 Add unit test for `encodeEIP1271Signature` in `apps/web/src/features/proposers/utils/utils.test.ts`. Test cases: (1) correct byte layout for a known parent Safe address and signature, (2) output is a valid hex string, (3) r-value contains the parent Safe address left-padded to 32 bytes, (4) v-value is 0x00, (5) s-value is 65 (0x41). Use faker for test addresses.
**Checkpoint**: EIP-1271 encoding utility is available and tested.
---
## Phase 2: User Story 1 + User Story 2 - Permission Gate Fix (Priority: P1) 🎯 MVP
**Goal**: Enable the "Add proposer" button for nested Safe owners while keeping it disabled for non-owners and proposer-only users.
**Independent Test**: Connect a wallet that owns a parent Safe (which is an owner of the nested Safe), navigate to Settings > Setup, verify "Add proposer" button is enabled. Connect an unrelated wallet, verify button remains disabled with tooltip.
### Implementation
- [x] T003 [US1] Replace `OnlyOwner` wrapper with `CheckWallet` component around the "Add proposer" button in `apps/web/src/components/settings/ProposersList/index.tsx`. Use props `allowProposer={false}` to prevent proposers from managing other proposers while allowing nested Safe owners. Remove the `OnlyOwner` import if no longer used in the file. Add `CheckWallet` import from `@/components/common/CheckWallet`.
- [x] T004 [P] [US1] Add unit tests for ProposersList permission behavior in `apps/web/src/components/settings/ProposersList/index.test.tsx`. Test cases using MSW and React Testing Library: (1) button enabled when `useIsNestedSafeOwner` returns true and Safe is deployed, (2) button disabled when user is only a proposer (not owner/nested owner), (3) button disabled when user has no relationship to the Safe (shows tooltip "Your connected wallet is not a signer of this Safe Account"), (4) button disabled when Safe is undeployed (shows "activate Safe" tooltip), (5) button enabled for direct Safe owner (no regression). Mock `useIsNestedSafeOwner`, `useIsSafeOwner`, `useIsWalletProposer` hooks.
**Checkpoint**: Button correctly reflects permissions for nested owners, direct owners, proposers, and non-owners.
---
## Phase 3: User Story 3 - EIP-1271 Signing Flow (Priority: P2)
**Goal**: When a nested Safe owner submits the proposer form, sign with the connected wallet and wrap in EIP-1271 format with the parent Safe as delegator.
**Independent Test**: As a nested Safe owner, click "Add proposer", enter a valid address and label, submit — verify the delegate API is called with `delegator: parentSafeAddress` and `signature` in EIP-1271 format.
### Implementation
- [x] T005 [US3] Modify `apps/web/src/features/proposers/components/UpsertProposer.tsx` to detect nested Safe ownership and use parent Safe as delegator. Changes: (1) Import and call `useIsNestedSafeOwner()` and `useNestedSafeOwners()` hooks. (2) In the form submission handler, after signing with the connected wallet (existing `signProposerTypedData`/`signProposerData`), check if user is a nested Safe owner. (3) If nested Safe owner: get the first parent Safe address from `useNestedSafeOwners()`, call `encodeEIP1271Signature(parentSafeAddress, eoaSignature)` to wrap, and set `delegator` to `parentSafeAddress` in the API payload. (4) If direct owner: keep existing behavior unchanged (delegator = wallet.address, raw EOA signature).
- [x] T006 [P] [US3] Add unit tests for UpsertProposer nested Safe flow in `apps/web/src/features/proposers/components/UpsertProposer.test.tsx`. Test cases: (1) When user is nested Safe owner and submits form, the delegate API mutation is called with `delegator` set to the parent Safe address (not the wallet address). (2) When user is nested Safe owner, the `signature` field in the API call is the EIP-1271 wrapped version. (3) When user is a direct owner, the delegate API is called with `delegator` set to the wallet address and raw EOA signature (no regression). (4) When user has multiple parent Safes, the first one is used as delegator. Use MSW for API mocking, mock the nested Safe ownership hooks.
**Checkpoint**: End-to-end flow works — nested Safe owner can add a proposer with parent Safe as delegator using EIP-1271 signature.
---
## Phase 4: Polish & Validation
**Purpose**: Quality gates, type checking, and validation across all stories.
- [x] T007 Run `yarn workspace @safe-global/web type-check` and fix any TypeScript errors introduced by the changes in ProposersList, UpsertProposer, and utils.ts.
- [x] T008 Run `yarn workspace @safe-global/web lint` and fix any linting issues.
- [x] T009 Run `yarn workspace @safe-global/web test` to verify all existing tests pass (no regressions) and all new tests pass.
- [x] T010 Verify edge case handling: (1) Confirm undeployed Safe still shows disabled button regardless of nested ownership, (2) Confirm address validation (not self, not existing owner) works for nested Safe owner flow, (3) Confirm ETH_SIGN wallet detection still works correctly for nested Safe owners (Trezor/Keystone use `signProposerData` fallback).
---
## Dependencies & Execution Order
### Phase Dependencies
- **Foundational (Phase 1)**: No dependencies — can start immediately
- **US1+US2 (Phase 2)**: No dependency on Phase 1 (different file: ProposersList vs utils.ts)
- **US3 (Phase 3)**: Depends on Phase 1 (needs `encodeEIP1271Signature` from utils.ts)
- **Polish (Phase 4)**: Depends on all previous phases
### User Story Dependencies
- **User Story 1 (P1)**: Permission gate change is independent. Full end-to-end flow requires US3 signing flow.
- **User Story 2 (P2)**: Automatically satisfied by US1's CheckWallet change (non-owners/proposers blocked via `allowProposer={false}`).
- **User Story 3 (P2)**: Depends on Phase 1 foundational utility. Independent of US1 permission gate (different file).
### Within Each Phase
- Tasks marked [P] within the same phase can run in parallel
- T001 (utility) before T005 (UpsertProposer uses utility)
- T003 (ProposersList) and T001 (utils) are in different files — can run in parallel
- Tests ([P] marked) can run in parallel with their phase's implementation if in different files
### Parallel Opportunities
- **Phase 1 + Phase 2 can run in parallel** (different files: utils.ts vs ProposersList)
- T002 and T004 can run in parallel (different test files)
- T005 and T003 are independent (different files)
- T006 and T004 can run in parallel (different test files)
---
## Parallel Example: Phase 1 + Phase 2
```bash
# These can run simultaneously (different files):
Task: "T001 - encodeEIP1271Signature in utils.ts"
Task: "T003 - Replace OnlyOwner with CheckWallet in ProposersList"
# Then tests in parallel:
Task: "T002 - Unit test for EIP-1271 encoding in utils.test.ts"
Task: "T004 - Unit tests for ProposersList in index.test.tsx"
```
---
## Implementation Strategy
### MVP First (User Story 1 - Permission Gate)
1. Complete Phase 1 (foundational utility) + Phase 2 (permission gate) in parallel
2. **STOP and VALIDATE**: Verify button is enabled for nested Safe owners, disabled for non-owners
3. This alone delivers visible value (unblocks the button)
### Full Feature Delivery
1. Complete Phase 1 + Phase 2 (in parallel) → Button works correctly
2. Complete Phase 3 → Signing flow uses EIP-1271 with parent Safe as delegator
3. Complete Phase 4 → All quality gates pass
4. **Full feature complete**: Nested Safe owners can add proposers end-to-end
### Scope Note
Initial implementation targets 1-of-1 parent Safes (single connected wallet is sole owner of parent Safe). Multi-sig parent Safes (threshold > 1) requiring collection of multiple owner signatures are deferred to a future iteration.
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- US2 is implicitly covered by US1's CheckWallet change — no separate implementation needed
- The `encodeEIP1271Signature` utility is the only new function; all other changes modify existing files
- Existing hooks (`useIsNestedSafeOwner`, `useNestedSafeOwners`) require no changes
- Existing signing functions (`signProposerTypedData`, `signProposerData`) require no changes — EIP-1271 wrapping happens after signing
@@ -0,0 +1,36 @@
# Specification Quality Checklist: Proposer Multisig Validation for 2/N Parent Safes
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: 2026-01-23
**Feature**: [spec.md](../spec.md)
## Content Quality
- [x] No implementation details (languages, frameworks, APIs)
- [x] Focused on user value and business needs
- [x] Written for non-technical stakeholders
- [x] All mandatory sections completed
## Requirement Completeness
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] Requirements are testable and unambiguous
- [x] Success criteria are measurable
- [x] Success criteria are technology-agnostic (no implementation details)
- [x] All acceptance scenarios are defined
- [x] Edge cases are identified
- [x] Scope is clearly bounded
- [x] Dependencies and assumptions identified
## Feature Readiness
- [x] All functional requirements have clear acceptance criteria
- [x] User scenarios cover primary flows
- [x] Feature meets measurable outcomes defined in Success Criteria
- [x] No implementation details leak into specification
## Notes
- Spec references EIP-1271 and TOTP as domain-specific concepts (not implementation details) — these are protocol-level standards relevant to understanding the problem.
- The spec intentionally leaves the storage mechanism for pending delegation requests as an implementation decision (noted in Assumptions).
- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`.
@@ -0,0 +1,284 @@
# API Contracts: Proposer Multisig Validation
This feature uses **existing** CGW/Transaction Service endpoints. No new endpoints are needed. This document describes how the existing APIs are composed to implement the multi-sig delegation flow.
## Endpoints Used
### 1. Create Off-Chain Message (Initiate Delegation)
**Endpoint**: `POST /v1/chains/{chainId}/safes/{parentSafeAddress}/messages`
Creates a new off-chain SafeMessage on the parent Safe containing the delegate TypedData.
**Request**:
```typescript
interface CreateMessageDto {
message: TypedData // The delegate EIP-712 typed data
signature: string // Initiating owner's signature on the SafeMessage
safeAppId: null
origin: string // JSON-encoded DelegationOrigin metadata
}
```
**Example Request Body**:
```json
{
"message": {
"domain": {
"name": "Safe Transaction Service",
"version": "1.0",
"chainId": 1
},
"types": {
"Delegate": [
{ "name": "delegateAddress", "type": "address" },
{ "name": "totp", "type": "uint256" }
]
},
"message": {
"delegateAddress": "0x1234...proposer",
"totp": 497142
},
"primaryType": "Delegate"
},
"signature": "0xabc...ownerA_signature",
"safeAppId": null,
"origin": "{\"type\":\"proposer-delegation\",\"action\":\"add\",\"delegate\":\"0x1234...proposer\",\"nestedSafe\":\"0x5678...nested\",\"label\":\"My Proposer\"}"
}
```
**Response**: `201 Created` (Message object)
**Signing Process** (for the initiating owner):
```typescript
// 1. Generate delegate typed data
const delegateTypedData = getDelegateTypedData(chainId, proposerAddress)
// 2. Generate SafeMessage typed data wrapping the delegate hash
const safeMessageTypedData = generateSafeMessageTypedData(parentSafe, delegateTypedData)
// 3. Sign the SafeMessage with the owner's wallet
const signature = await tryOffChainMsgSigning(signer, parentSafe, delegateTypedData)
```
---
### 2. Confirm Off-Chain Message (Co-owner Signs)
**Endpoint**: `POST /v1/chains/{chainId}/messages/{messageHash}/signatures`
Adds a co-owner's signature to an existing delegation message.
**Request**:
```typescript
interface UpdateMessageSignatureDto {
signature: string // Co-owner's signature on the SafeMessage
}
```
**Example Request Body**:
```json
{
"signature": "0xdef...ownerB_signature"
}
```
**Response**: `200 OK`
**Signing Process** (for confirming owners):
```typescript
// 1. Fetch the message to get its content (delegate TypedData)
const message = await getMessageByHash(chainId, messageHash)
// 2. Sign the same SafeMessage that the initiator signed
const signature = await tryOffChainMsgSigning(signer, parentSafe, message.message)
```
---
### 3. Get Messages by Safe (Discover Pending Delegations)
**Endpoint**: `GET /v1/chains/{chainId}/safes/{parentSafeAddress}/messages`
Fetches all off-chain messages for the parent Safe. Client filters by `origin.type === 'proposer-delegation'`.
**Query Parameters**:
```typescript
interface GetMessagesArgs {
limit?: number // Pagination
offset?: number
}
```
**Response**:
```typescript
interface MessagePage {
count: number | null
next: string | null
previous: string | null
results: Message[]
}
interface Message {
messageHash: string
status: 'NEEDS_CONFIRMATION' | 'CONFIRMED'
logoUri: string | null
name: string | null
message: string | TypedData // The delegate TypedData
creationTimestamp: number
modifiedTimestamp: number
confirmationsSubmitted: number // Current signature count
confirmationsRequired: number // Parent Safe threshold
proposedBy: AddressInfo
confirmations: MessageConfirmation[]
preparedSignature: string | null // All sigs concatenated when CONFIRMED
origin: string | null // Our DelegationOrigin JSON
}
interface MessageConfirmation {
owner: AddressInfo
signature: string
}
```
**Client-Side Filtering**:
```typescript
const pendingDelegations = messages.results.filter((msg) => {
try {
const origin = JSON.parse(msg.origin || '')
return origin.type === 'proposer-delegation' && origin.nestedSafe === currentSafeAddress
} catch {
return false
}
})
```
---
### 4. Submit Completed Delegation (Add Proposer)
**Endpoint**: `POST /v2/chains/{chainId}/delegates`
Submits the completed multi-sig EIP-1271 signature to register the delegate.
**Request**:
```typescript
interface CreateDelegateDto {
safe: string // Nested Safe address
delegate: string // Proposer address
delegator: string // Parent Safe address
signature: string // EIP-1271 wrapped preparedSignature
label: string // Proposer label
}
```
**Signature Assembly**:
```typescript
// 1. Get preparedSignature from confirmed message
const confirmedMessage = await getMessageByHash(chainId, messageHash)
const innerSignatures = confirmedMessage.preparedSignature!
// 2. Wrap in EIP-1271 format
const eip1271Signature = encodeEIP1271Signature(parentSafeAddress, innerSignatures)
// 3. Submit
await addDelegateV2({
chainId,
createDelegateDto: {
safe: nestedSafeAddress,
delegate: proposerAddress,
delegator: parentSafeAddress,
signature: eip1271Signature,
label: proposerLabel,
},
})
```
---
### 5. Submit Completed Delegation (Remove Proposer)
**Endpoint**: `DELETE /v2/chains/{chainId}/delegates/{delegateAddress}`
Submits the completed multi-sig EIP-1271 signature to remove the delegate.
**Request Body**:
```typescript
interface DeleteDelegateV2Dto {
delegator: string // Parent Safe address
safe: string // Nested Safe address
signature: string // EIP-1271 wrapped preparedSignature
}
```
**Same signature assembly as addition** — the delegate TypedData structure is the same for both add and remove operations. The only difference is the HTTP method and endpoint.
---
## Data Flow Sequence
```
Owner A (Initiator) CGW / Transaction Service Owner B (Co-signer)
│ │ │
│ 1. Sign SafeMessage(delegateTypedData) │ │
│───────────────────────────────────────►│ │
│ POST /safes/{parent}/messages │ │
│ │ │
│ 2. Receive messageHash │ │
│◄───────────────────────────────────────│ │
│ │ │
│ │ 3. Owner B opens settings page │
│ │◄─────────────────────────────────────│
│ │ GET /safes/{parent}/messages │
│ │ │
│ │ 4. Returns pending delegation │
│ │─────────────────────────────────────►│
│ │ (status: NEEDS_CONFIRMATION) │
│ │ │
│ │ 5. Owner B signs & confirms │
│ │◄─────────────────────────────────────│
│ │ POST /messages/{hash}/signatures │
│ │ │
│ │ 6. Returns updated message │
│ │─────────────────────────────────────►│
│ │ (status: CONFIRMED, │
│ │ preparedSignature: "0x...") │
│ │ │
│ │ 7. Wrap & submit to delegate API │
│ │◄─────────────────────────────────────│
│ │ POST /delegates (EIP-1271 sig) │
│ │ │
│ │ 8. Proposer registered ✓ │
│ │─────────────────────────────────────►│
```
## EIP-1271 Signature Encoding
```
┌─────────────────────────────────────────────────────────────┐
│ Byte Range │ Content │
├─────────────┼───────────────────────────────────────────────│
│ 0-31 │ r: parentSafeAddress left-padded to 32 bytes │
│ 32-63 │ s: 0x00...0041 (offset 65 to dynamic data) │
│ 64 │ v: 0x00 (contract signature type indicator) │
│ 65-96 │ length: byte length of preparedSignature │
│ 97+ │ preparedSignature (N × 65 bytes, sorted) │
└─────────────┴───────────────────────────────────────────────┘
```
For a 2-of-3 parent Safe:
- Inner signatures: 2 × 65 = 130 bytes
- Total EIP-1271 signature: 65 (header) + 32 (length) + 130 (sigs) = 227 bytes
@@ -0,0 +1,180 @@
# Data Model: Proposer Multisig Validation
## Entities
### PendingDelegation (derived from off-chain SafeMessage)
A pending proposer delegation request stored as an off-chain SafeMessage on the parent Safe. Not a new entity — a view/projection of existing `Message` responses filtered by origin metadata.
| Field | Type | Source | Description |
| ---------------------- | ----------------------------------- | ---------------------------------------- | ---------------------------------------------------------- |
| messageHash | `Hex` | `Message.messageHash` | Unique identifier of the off-chain message |
| action | `'add' \| 'remove'` | Parsed from `Message.origin` | Whether this is an add or remove delegation |
| delegateAddress | `Address` | Parsed from `Message.origin` + TypedData | The proposer address being added/removed |
| delegateLabel | `string` | Parsed from `Message.origin` | Human-readable label for the proposer |
| nestedSafeAddress | `Address` | Parsed from `Message.origin` | The nested Safe this delegation targets |
| parentSafeAddress | `Address` | Context (URL of message creation) | The parent Safe collecting signatures |
| totp | `number` | Parsed from delegate TypedData message | The TOTP value used at creation time |
| status | `'pending' \| 'ready' \| 'expired'` | Derived | Computed from confirmations vs threshold and TOTP validity |
| confirmationsSubmitted | `number` | `Message.confirmationsSubmitted` | Number of owner signatures collected |
| confirmationsRequired | `number` | `Message.confirmationsRequired` | Parent Safe threshold |
| confirmations | `MessageConfirmation[]` | `Message.confirmations` | Individual owner signature records |
| preparedSignature | `Hex \| null` | `Message.preparedSignature` | All signatures concatenated (available when confirmed) |
| creationTimestamp | `number` | `Message.creationTimestamp` | When the delegation request was initiated |
| proposedBy | `AddressInfo` | `Message.proposedBy` | The owner who initiated the request |
### DelegationOrigin (stored in Message.origin field)
Structured metadata stored as JSON string in the off-chain message's `origin` field.
```typescript
interface DelegationOrigin {
type: 'proposer-delegation'
action: 'add' | 'remove'
delegate: Address
nestedSafe: Address
label: string
}
```
### ParentSafeInfo (fetched from CGW)
Threshold and owner data for the parent Safe, used to determine signing flow.
| Field | Type | Source | Description |
| --------- | --------------- | ----------------------- | ----------------------------- |
| address | `Address` | `useNestedSafeOwners()` | Parent Safe address |
| threshold | `number` | `SafeState.threshold` | Required number of signatures |
| owners | `AddressInfo[]` | `SafeState.owners` | List of owner addresses |
| chainId | `string` | Current chain context | Chain the Safe is deployed on |
## State Transitions
### PendingDelegation Lifecycle
```
┌─────────────┐
│ (none) │
└──────┬──────┘
│ Owner A initiates delegation
│ (creates off-chain message)
┌─────────────┐
│ PENDING │ confirmationsSubmitted < confirmationsRequired
│ │ AND TOTP is valid
└──────┬──────┘
┌────────────┼────────────┐
│ │ │
│ Owner B │ TOTP │ Owner removed /
│ confirms │ expires │ threshold changes
▼ ▼ ▼
┌─────────────┐ ┌─────────┐ ┌──────────────┐
│ READY │ │ EXPIRED │ │ INVALID │
│ threshold │ │ │ │ (re-validate │
│ met │ │ │ │ on display) │
└──────┬───────┘ └────┬────┘ └──────────────┘
│ │
│ Submit to │ User re-initiates
│ delegate API │ (new message, new TOTP)
▼ ▼
┌─────────────┐ ┌─────────────┐
│ SUBMITTED │ │ (none) │
│ (proposer │ │ old msg │
│ appears) │ │ remains │
└─────────────┘ └─────────────┘
```
### Status Derivation Logic
```typescript
function deriveDelegationStatus(message: Message, currentTotp: number): 'pending' | 'ready' | 'expired' {
const messageTotp = (message.message as TypedData).message.totp
const totpDiff = currentTotp - messageTotp
// TOTP valid window: current hour and previous hour (±1)
if (totpDiff > 1) return 'expired'
if (message.confirmationsSubmitted >= message.confirmationsRequired) {
return 'ready'
}
return 'pending'
}
```
## Relationships
```
┌──────────────────┐ ┌──────────────────┐
│ Nested Safe │◄────────│ Parent Safe │
│ (current) │ owns │ (delegator) │
└──────────────────┘ └────────┬─────────┘
│ has off-chain messages
┌──────────────────┐
│ SafeMessage │
│ (off-chain) │
│ │
│ origin: { │
│ type: "proposer│
│ -delegation" │
│ } │
└────────┬─────────┘
│ has confirmations
┌──────────────────┐
│ MessageConfirm- │
│ ation │
│ (per owner sig) │
└──────────────────┘
```
## TypeScript Interfaces
```typescript
// Origin metadata for delegation messages
interface DelegationOrigin {
type: 'proposer-delegation'
action: 'add' | 'remove'
delegate: Address
nestedSafe: Address
label: string
}
// Parsed pending delegation (view model)
interface PendingDelegation {
messageHash: Hex
action: 'add' | 'remove'
delegateAddress: Address
delegateLabel: string
nestedSafeAddress: Address
parentSafeAddress: Address
totp: number
status: 'pending' | 'ready' | 'expired'
confirmationsSubmitted: number
confirmationsRequired: number
confirmations: MessageConfirmation[]
preparedSignature: Hex | null
creationTimestamp: number
proposedBy: AddressInfo
}
// Parent Safe info needed for threshold check
interface ParentSafeInfo {
address: Address
threshold: number
owners: AddressInfo[]
chainId: string
}
```
## Validation Rules
1. **TOTP validity**: `currentTotp - messageTotp <= 1` (within ~2 hour window)
2. **Owner verification**: Only current owners of the parent Safe can sign confirmations (enforced by Transaction Service)
3. **Signature ordering**: `preparedSignature` is pre-sorted by owner address ascending (handled by Transaction Service `build_signature()`)
4. **Threshold satisfaction**: `confirmationsSubmitted >= confirmationsRequired` before submission to delegate API
5. **Address checksumming**: All addresses use EIP-55 checksummed format for comparison
@@ -0,0 +1,90 @@
# Implementation Plan: Proposer Multisig Validation for 2/N Parent Safes
**Branch**: `004-proposer-multisig-validation` | **Date**: 2026-01-23 | **Spec**: [spec.md](./spec.md)
**Input**: Feature specification from `/specs/004-proposer-multisig-validation/spec.md`
## Summary
Enable proposer delegation (add/remove) for nested Safes whose parent Safe has a threshold > 1. The current implementation assumes a 1/1 parent Safe and submits a single EOA signature wrapped in EIP-1271 format, which fails backend validation for 2/N+ parent Safes. The solution leverages the existing Safe Transaction Service off-chain message signing infrastructure to collect multiple owner signatures before submitting the completed EIP-1271-wrapped delegation.
**Technical Approach**: Store the delegate typed data as an off-chain SafeMessage on the parent Safe. Each parent Safe owner signs the message via the existing off-chain message confirmation API. Once `confirmationsSubmitted >= confirmationsRequired` (threshold met), the `preparedSignature` (all owner signatures concatenated and sorted by address) is wrapped in EIP-1271 format and submitted to the delegate API.
## Technical Context
**Language/Version**: TypeScript 5.x (Next.js 14.x)
**Primary Dependencies**: React, MUI, Redux Toolkit (RTK Query), ethers.js, @safe-global/protocol-kit, @safe-global/api-kit
**Storage**: Safe Transaction Service off-chain messages (existing infrastructure, no new storage)
**Testing**: Jest + MSW (Mock Service Worker) + faker
**Target Platform**: Web (apps/web)
**Project Type**: Web application (monorepo, apps/web only — web-only feature)
**Performance Goals**: Signature collection must complete within TOTP validity window (~2 hours)
**Constraints**: No backend modifications; uses existing CGW/Transaction Service APIs as-is
**Scale/Scope**: Parent Safes with up to 5 owners; direct parent only (one level of nesting)
## Constitution Check
_GATE: Must pass before Phase 0 research. Re-check after Phase 1 design._
| Principle | Status | Notes |
| ----------------------- | ------- | ------------------------------------------------------------------------------------ |
| I. Type Safety | ✅ PASS | All new code will use proper TypeScript interfaces; no `any` |
| II. Branch Protection | ✅ PASS | Feature branch `004-proposer-multisig-validation`; all quality gates will run |
| III. Cross-Platform | ✅ PASS | Web-only feature in `apps/web/`; no shared package changes needed |
| IV. Testing Discipline | ✅ PASS | MSW for network mocking, faker for test data, colocated tests |
| V. Feature Organization | ✅ PASS | Changes within existing `src/features/proposers/`; existing feature flag covers this |
| VI. Theme System | ✅ PASS | Uses MUI components and theme tokens only |
**Gate Result**: ALL PASS — proceed to Phase 0.
**Post-Design Re-Check** (after Phase 1):
- All new TypeScript interfaces properly typed (PendingDelegation, DelegationOrigin, ParentSafeInfo)
- All new files within `src/features/proposers/` — no cross-feature leakage
- MSW handlers defined for all network interactions in test plan
- No hardcoded values; MUI components only
- ✅ ALL PASS — design is constitution-compliant
## Project Structure
### Documentation (this feature)
```text
specs/004-proposer-multisig-validation/
├── plan.md # This file
├── research.md # Phase 0 output
├── data-model.md # Phase 1 output
├── quickstart.md # Phase 1 output
├── contracts/ # Phase 1 output
└── tasks.md # Phase 2 output (/speckit.tasks command)
```
### Source Code (repository root)
```text
apps/web/src/
├── features/proposers/
│ ├── components/
│ │ ├── UpsertProposer.tsx # MODIFY: branch on threshold, initiate multi-sig flow
│ │ ├── DeleteProposerDialog.tsx # MODIFY: same multi-sig branching for removal
│ │ ├── PendingDelegation.tsx # NEW: pending delegation card with progress
│ │ └── PendingDelegationsList.tsx # NEW: list of pending delegations on settings page
│ ├── hooks/
│ │ ├── useParentSafeThreshold.ts # NEW: fetch parent Safe threshold + owners
│ │ ├── usePendingDelegations.ts # NEW: fetch/filter off-chain messages for delegations
│ │ └── useSubmitDelegation.ts # NEW: wrap preparedSignature in EIP-1271, submit
│ ├── services/
│ │ └── delegationMessages.ts # NEW: create/confirm off-chain messages on parent Safe
│ └── utils/
│ └── utils.ts # MODIFY: add multi-sig signature helpers
├── components/settings/
│ └── ProposersList/
│ └── index.tsx # MODIFY: integrate PendingDelegationsList
└── hooks/
└── useNestedSafeOwners.tsx # UNCHANGED: already provides parent Safe address
```
**Structure Decision**: All changes are within the existing `apps/web/src/features/proposers/` feature folder, with minimal integration points in the settings page. No new top-level folders needed.
## Complexity Tracking
No constitution violations — table not needed.
@@ -0,0 +1,143 @@
# Quickstart: Proposer Multisig Validation
## Prerequisites
- Node.js 18+
- Yarn 4 (via corepack)
- A test environment with:
- A 2-of-3 Safe ("parent Safe") deployed on a testnet
- A nested Safe owned by the parent Safe
- Access to at least 2 owner wallets of the parent Safe
## Setup
```bash
# Install dependencies
yarn install
# Start the web app in development mode (points to staging backend)
yarn workspace @safe-global/web dev
```
## Key Files to Understand
Before implementing, read these files in order:
1. **Current signing flow**: `apps/web/src/features/proposers/utils/utils.ts`
- `getDelegateTypedData()` — generates the EIP-712 typed data
- `signProposerTypedDataForSafe()` — signs wrapped in SafeMessage for nested owners
- `encodeEIP1271Signature()` — encodes for contract signature validation
2. **Current UI flow**: `apps/web/src/features/proposers/components/UpsertProposer.tsx`
- `onConfirm()` handler — branches on nested ownership
- Currently submits immediately (works for 1/1, fails for 2/N)
3. **Off-chain message infrastructure**: `apps/web/src/services/safe-messages/safeMsgSender.ts`
- `dispatchSafeMsgProposal()` — creates new off-chain message
- `dispatchSafeMsgConfirmation()` — adds signature to existing message
4. **Nested ownership detection**: `apps/web/src/hooks/useNestedSafeOwners.tsx`
- Returns parent Safe addresses that own the current Safe
5. **RTK Query hooks**: `packages/store/src/gateway/AUTO_GENERATED/messages.ts`
- `useMessagesCreateMessageV1Mutation` — create message
- `useMessagesUpdateMessageSignatureV1Mutation` — confirm message
- `useMessagesGetMessagesBySafeV1Query` — list messages
## Implementation Order
### Step 1: Parent Safe Threshold Hook
Create `apps/web/src/features/proposers/hooks/useParentSafeThreshold.ts`:
- Uses `useNestedSafeOwners()` to get parent Safe address
- Fetches parent Safe info via `useSafesGetSafeV1Query`
- Returns `{ threshold, owners, parentSafeAddress }`
### Step 2: Delegation Message Service
Create `apps/web/src/features/proposers/services/delegationMessages.ts`:
- `createDelegationMessage(parentSafe, delegateTypedData, signature, origin)` — wraps RTK Query mutation
- `confirmDelegationMessage(chainId, messageHash, signature)` — wraps RTK Query mutation
- `buildDelegationOrigin(action, delegate, nestedSafe, label)` — creates origin metadata
### Step 3: Pending Delegations Hook
Create `apps/web/src/features/proposers/hooks/usePendingDelegations.ts`:
- Fetches messages for parent Safe via `useMessagesGetMessagesBySafeV1Query`
- Filters by `origin.type === 'proposer-delegation'`
- Filters by `origin.nestedSafe === currentSafeAddress`
- Derives status (pending/ready/expired) based on TOTP and confirmations
- Returns typed `PendingDelegation[]`
### Step 4: Submit Delegation Hook
Create `apps/web/src/features/proposers/hooks/useSubmitDelegation.ts`:
- Takes a confirmed `PendingDelegation` (with `preparedSignature`)
- Wraps `preparedSignature` in EIP-1271 format via `encodeEIP1271Signature`
- Submits to delegate API (V2) — add or remove based on `action`
### Step 5: Modify UpsertProposer Component
Modify `apps/web/src/features/proposers/components/UpsertProposer.tsx`:
- Check parent Safe threshold before signing
- If threshold === 1: existing flow (unchanged)
- If threshold > 1: create off-chain message, show pending state
- Add messaging about multi-sig requirement
### Step 6: Modify DeleteProposerDialog Component
Same branching logic as UpsertProposer for the removal flow.
### Step 7: Pending Delegations UI Components
Create `PendingDelegation.tsx` and `PendingDelegationsList.tsx`:
- Show pending delegation cards with progress (e.g., "1 of 2 signatures")
- Allow co-owners to sign pending requests
- Show "Submit" button when threshold is met
- Show "Expired" indicator with re-initiate option
### Step 8: Integrate into Settings Page
Modify `apps/web/src/components/settings/ProposersList/index.tsx`:
- Add `PendingDelegationsList` above or below the existing proposers list
- Only visible when parent Safe threshold > 1 and pending delegations exist
## Testing Approach
```bash
# Run tests
yarn workspace @safe-global/web test --watch
# Run type-check
yarn workspace @safe-global/web type-check
```
**MSW Handlers needed**:
- `GET /v1/chains/:chainId/safes/:safeAddress` — mock parent Safe with threshold=2
- `POST /v1/chains/:chainId/safes/:safeAddress/messages` — mock message creation
- `POST /v1/chains/:chainId/messages/:hash/signatures` — mock confirmation
- `GET /v1/chains/:chainId/safes/:safeAddress/messages` — mock message list
- `POST /v2/chains/:chainId/delegates` — mock delegate creation
- `DELETE /v2/chains/:chainId/delegates/:address` — mock delegate deletion
## Verification
After implementation, verify the full flow:
1. Connect as Owner A of a 2/3 parent Safe
2. Navigate to nested Safe → Settings → Proposers
3. Click "Add proposer" — should see multi-sig messaging
4. Sign — should create off-chain message and show pending state
5. Connect as Owner B of the same parent Safe
6. Navigate to same nested Safe → Settings → Proposers
7. See pending delegation — sign to confirm
8. After threshold met — delegation should auto-submit
9. Proposer should appear in the list
@@ -0,0 +1,127 @@
# Research: Proposer Multisig Validation for 2/N Parent Safes
## Decision 1: Off-Chain Message Content Format
**Decision**: Pass the delegate EIP-712 TypedData object as the `message` field when creating the off-chain SafeMessage on the parent Safe.
**Rationale**: The off-chain message API accepts either a string or TypedData object. When TypedData is provided, the Transaction Service:
1. Computes the EIP-712 hash of the TypedData → `delegateHash`
2. Wraps in `SafeMessage { message: delegateHash }` with `verifyingContract = parentSafe`
3. Each owner signs this SafeMessage typed data
This produces signatures that are valid for the Safe contract's `isValidSignature(delegateHash, signatures)` because the contract internally reconstructs the same SafeMessage wrapping before verifying signatures.
**Alternatives considered**:
- Pass raw delegate hash as string: Would be double-hashed (keccak256 of the hash), producing invalid signatures for the delegate API
- Pass custom wrapper message: Unnecessary complexity; the TypedData message is already the correct primitive
## Decision 2: Off-Chain Message API Endpoint Targeting
**Decision**: Create off-chain messages on the **parent Safe's** address (not the nested Safe), using `POST /v1/chains/{chainId}/safes/{parentSafeAddress}/messages`.
**Rationale**:
- The CGW message endpoint uses the Safe's threshold as `confirmationsRequired`
- The parent Safe's owners are the ones who need to sign
- The Transaction Service validates that each signer is an owner of the target Safe
- Using the parent Safe address ensures correct threshold and owner validation
**Alternatives considered**:
- Create message on nested Safe: Incorrect — the nested Safe's owners (which include the parent Safe) wouldn't match the EOA signers
- Custom storage mechanism: Unnecessary when existing infrastructure fits perfectly
## Decision 3: Identifying Delegation Messages Among All Off-Chain Messages
**Decision**: Include a structured `origin` field when creating the off-chain message to tag it as a delegation request. Format: `{"type":"proposer-delegation","action":"add"|"remove","delegate":"0x...","nestedSafe":"0x...","label":"..."}`.
**Rationale**:
- The off-chain message API has an `origin` field (string, optional) designed for metadata
- This allows filtering parent Safe messages to find only delegation-related ones
- The `origin` is stored and returned in message responses
- No collision with other off-chain messages on the parent Safe (dApp messages, etc.)
**Alternatives considered**:
- Filter by message content (TypedData structure): Works but fragile — other dApps could use similar typed data structures
- Store message hashes locally: Defeats the purpose of cross-device discovery
- Use a unique prefix in message content: The TypedData is fixed by the delegate API format
## Decision 4: EIP-1271 Signature Assembly from preparedSignature
**Decision**: Use the `preparedSignature` field from the off-chain message response directly as the inner signature data for EIP-1271 encoding.
**Rationale**:
- The Transaction Service's `build_signature()` method concatenates all confirmations sorted by owner address (ascending) — exactly what the Safe contract requires
- The CGW exposes this as `preparedSignature` when `status === 'CONFIRMED'`
- The existing `encodeEIP1271Signature` function can be adapted to accept multi-owner signature bytes instead of a single EOA signature
**Alternatives considered**:
- Manual concatenation on client: Redundant — the backend already does this correctly
- Fetching individual confirmations and sorting: Extra work with no benefit over preparedSignature
## Decision 5: TOTP Expiration Detection
**Decision**: Compare the TOTP value embedded in the delegate TypedData message with the current TOTP (± 1 hour tolerance). If the message's TOTP is outside this window, display as expired.
**Rationale**:
- The delegate TypedData contains `totp: Math.floor(Date.now() / 1000 / 3600)` at creation time
- The backend accepts current TOTP ± 1 previous interval (total ~2 hour window)
- Client-side detection avoids a round-trip to the backend to discover expiration
- Expired messages are shown with visual indicator; no cleanup needed
**Alternatives considered**:
- Server-side validation only (submit and handle 4xx): Poor UX — user collects signatures only to fail at submission
- Use `creationTimestamp` from message response: Less precise — doesn't account for exact TOTP boundaries
## Decision 6: Threshold Detection for Parent Safe
**Decision**: Fetch the parent Safe's info using the existing `useSafesGetSafeV1Query` RTK Query hook with the parent Safe's address and chain ID.
**Rationale**:
- The `useNestedSafeOwners()` hook already provides the parent Safe address
- The CGW `GET /v1/chains/{chainId}/safes/{safeAddress}` endpoint returns `threshold` and `owners[]`
- Reusing the existing RTK Query hook means automatic caching and refetching
**Alternatives considered**:
- Direct RPC call to Safe contract: Unnecessary complexity when CGW API provides the data
- Store threshold in local state: Would go stale; better to fetch fresh from CGW
## Decision 7: Auto-Submission After Threshold Met
**Decision**: When the current user's confirmation brings `confirmationsSubmitted` to equal `confirmationsRequired`, immediately attempt the delegate API submission. If another user completed the threshold, show a "Ready to submit" state that any owner can trigger.
**Rationale**:
- The user who provides the final signature has the best context to submit immediately
- For cases where another owner completed the threshold (discovered on page load), any authenticated owner should be able to trigger submission
- The `preparedSignature` is only available when `status === 'CONFIRMED'`
**Alternatives considered**:
- Always manual submission: Extra click for the common case (user provides final signature)
- Background polling + auto-submit: Could race with stale TOTP; explicit user action is safer
## Decision 8: Modifying encodeEIP1271Signature for Multi-Owner Signatures
**Decision**: The existing `encodeEIP1271Signature(parentSafeAddress, ownerSignature)` function already works for multi-owner signatures because the EIP-1271 format encodes the inner signature as ABI-encoded bytes regardless of length. The `preparedSignature` (multiple concatenated 65-byte signatures) is simply longer bytes that get ABI-encoded the same way.
**Rationale**:
- EIP-1271 contract signature format: `r(address) | s(offset=65) | v(0x00) | ABI.encode(bytes, innerSignatures)`
- The ABI encoding handles variable-length bytes naturally
- No code change needed to `encodeEIP1271Signature` — just pass the full `preparedSignature` as the signature parameter
**Alternatives considered**:
- Create a separate function for multi-sig: Unnecessary duplication; same encoding logic applies
- Manually construct the bytes: Error-prone and duplicates existing tested logic
@@ -0,0 +1,135 @@
# Feature Specification: Proposer Multisig Validation for 2/N Parent Safes
**Feature Branch**: `004-proposer-multisig-validation`
**Created**: 2026-01-23
**Status**: Draft
**Input**: User description: "In a previous spec 001-nested-safe-proposer we fixed a proposing bug, but it was assuming the parent safe was a 1/1 and it doesn't work for 2/N now. What do we need to do for 2/N validation to work."
## User Scenarios & Testing _(mandatory)_
### User Story 1 - Add Proposer via 2/N Parent Safe with Signature Collection (Priority: P1)
As a user whose connected wallet is a signer of a 2-of-N (or higher threshold) parent Safe that owns a nested Safe, I want to add a proposer to the nested Safe by collecting the required number of signatures from parent Safe owners, so that the delegation is properly authorized.
Currently, when a 2/N parent Safe owner tries to add a proposer to the nested Safe, the single EOA signature is wrapped in EIP-1271 format and submitted. The backend calls `isValidSignature` on the parent Safe contract, which rejects it because only 1 signature is present but the threshold requires 2 or more.
**Why this priority**: This is the core broken flow — users with multi-sig parent Safes cannot add proposers to nested Safes at all, receiving cryptic validation errors from the backend.
**Independent Test**: Can be fully tested by connecting a wallet that is a signer of a 2-of-3 parent Safe (which owns a nested Safe), initiating a proposer addition, collecting a second owner's signature, and verifying the proposer is successfully registered.
**Acceptance Scenarios**:
1. **Given** a user whose wallet is a signer of a 2-of-3 parent Safe that owns a nested Safe, **When** the user submits a proposer addition, **Then** the system initiates a signature collection flow rather than immediately submitting.
2. **Given** a signature collection has been initiated for a proposer addition on a 2/N parent Safe, **When** the required threshold of parent Safe owners have provided their signatures, **Then** the system wraps all collected signatures in EIP-1271 format and submits to the delegate API.
3. **Given** a 2/N parent Safe proposer addition is pending signatures, **When** the initiating user views the status, **Then** they see how many signatures have been collected versus how many are required.
---
### User Story 2 - Threshold-Aware UX Feedback (Priority: P1)
As a user with a multi-sig parent Safe, I want the interface to clearly indicate that adding a proposer requires multiple signatures, so I understand the process before initiating it.
**Why this priority**: Without upfront communication, users will be confused about why the flow doesn't complete immediately (as it does for 1/1 parent Safes). Clear messaging prevents user frustration and support requests.
**Independent Test**: Can be tested by connecting as a 2/N parent Safe owner and verifying the UI communicates the multi-signature requirement before and during the submission flow.
**Acceptance Scenarios**:
1. **Given** a user is a signer of a 2/N parent Safe that owns the current nested Safe, **When** they open the "Add proposer" dialog, **Then** they see messaging indicating that multiple parent Safe owners must sign to complete the delegation.
2. **Given** a user has initiated a proposer addition requiring 2+ signatures, **When** only their signature is collected so far, **Then** the interface shows a pending state with clear instructions on how remaining signers can approve.
---
### User Story 3 - Retrieve and Complete Pending Proposer Delegations (Priority: P2)
As a parent Safe co-owner, I want to view and sign pending proposer delegation requests initiated by another owner, so that the delegation can reach the required threshold.
**Why this priority**: For the multi-sig flow to work end-to-end, non-initiating owners must be able to discover and approve pending delegation requests.
**Independent Test**: Can be tested by having Owner A initiate a proposer delegation on the nested Safe, then connecting as Owner B of the same parent Safe and verifying they can see and sign the pending request.
**Acceptance Scenarios**:
1. **Given** Owner A initiated a proposer delegation on the nested Safe requiring 2 signatures, **When** Owner B (a co-signer of the parent Safe) navigates to the nested Safe's proposers settings page, **Then** they can see the pending delegation request listed with its current signature count. Discovery is passive — no active notifications are shown elsewhere in the UI.
2. **Given** Owner B views a pending delegation request, **When** they sign and submit their approval, **Then** the system detects the threshold is met, wraps all signatures in EIP-1271 format, and submits the completed delegation to the backend.
3. **Given** a pending delegation has expired (TOTP window exceeded ~2 hours), **When** any owner views it, **Then** the system indicates it has expired and allows re-initiation.
---
### User Story 4 - Remove Proposer via 2/N Parent Safe (Priority: P1)
As a user whose connected wallet is a signer of a 2-of-N parent Safe that owns a nested Safe, I want to remove a proposer from the nested Safe by collecting the required number of signatures from parent Safe owners, so that the removal is properly authorized.
**Why this priority**: The delegate API's DELETE endpoint also requires a valid EIP-1271 signature from the parent Safe. Without multi-sig support, 2/N parent Safe owners cannot remove proposers either.
**Independent Test**: Can be tested by connecting as a 2/N parent Safe owner, initiating a proposer removal, collecting the required co-signatures, and verifying the proposer is removed.
**Acceptance Scenarios**:
1. **Given** a user whose wallet is a signer of a 2-of-N parent Safe that owns a nested Safe with an existing proposer, **When** the user initiates proposer removal, **Then** the system initiates a signature collection flow (same as addition).
2. **Given** the required threshold of parent Safe owners have signed the removal request, **When** the system submits the deletion, **Then** the proposer is removed from the nested Safe's proposers list.
---
### Edge Cases
- What happens when the parent Safe threshold is met by the initiating user alone (1/1 case)? The existing single-step flow should continue working unchanged — no signature collection needed.
- What happens if a parent Safe owner is removed while a delegation signature collection is in progress? The pending request should be invalidated or refreshed to reflect the current owner set.
- What happens if the parent Safe threshold changes while signatures are being collected? The system should re-validate against the current threshold before submitting.
- What happens when the TOTP value rolls over during signature collection? The delegation hash changes hourly; all signatures must use the same TOTP. If the window expires before threshold is met, signers must restart with a fresh hash.
- What happens if there are multiple parent Safes that own the nested Safe (user controls more than one)? The system should use the first detected parent Safe (existing behavior) or allow the user to choose.
## Clarifications
### Session 2026-01-23
- Q: Where should pending delegation requests (collected signatures awaiting threshold) be stored? → A: Safe Transaction Service off-chain messages — leverages existing infra; co-owners discover pending requests automatically.
- Q: Does this feature also cover removing proposers from nested Safes with 2/N parent Safes, or only adding? → A: Both add and remove — apply the same multi-sig signature collection flow to proposer removal.
- Q: How should co-owners discover pending delegation requests that need their signature? → A: Passive — co-owners navigate to proposers settings page where pending requests are listed.
- Q: What nesting depth does this feature support for the parent Safe relationship? → A: Direct parent only — only the immediate parent Safe (one level up) is supported.
- Q: How should expired pending delegation requests be handled? → A: Display-only — show as expired in UI, no cleanup; user re-initiates with fresh TOTP.
## Requirements _(mandatory)_
### Functional Requirements
- **FR-001**: System MUST detect the parent Safe's threshold before initiating the proposer delegation signing flow.
- **FR-002**: System MUST branch the signing flow based on threshold: if threshold = 1, use the existing single-step flow; if threshold > 1, initiate a multi-signature collection flow. This applies to both adding and removing proposers.
- **FR-003**: System MUST allow the initiating user to sign the delegate typed data hash and store their signature as the first collected signature.
- **FR-004**: System MUST persist pending delegation requests (with collected signatures) via the Safe Transaction Service off-chain message signing infrastructure so that other parent Safe owners can discover and sign them across devices/sessions.
- **FR-005**: System MUST allow other parent Safe owners to view pending delegation requests (fetched from the Transaction Service) and add their signatures.
- **FR-006**: System MUST automatically submit the completed EIP-1271-wrapped delegation to the delegate API once the threshold number of valid signatures is collected.
- **FR-007**: System MUST concatenate all collected owner signatures (sorted by owner address, ascending) before encoding in EIP-1271 format, as required by the Safe contract's signature validation.
- **FR-008**: System MUST display the current signature collection progress (e.g., "1 of 2 signatures collected") to users.
- **FR-009**: System MUST handle TOTP expiration by displaying pending requests whose signatures were generated in a previous TOTP window beyond the backend's tolerance (~2 hours) as expired. Expired messages are not deleted from the Transaction Service; users may re-initiate with a fresh TOTP.
- **FR-010**: System MUST continue to support the existing 1/1 parent Safe flow without regression.
### Key Entities
- **Parent Safe**: A multi-signature wallet with threshold >= 1 that is an owner of the nested Safe. Its owners must collectively sign the delegation.
- **Pending Delegation Request**: A record of an in-progress proposer delegation that has not yet reached the parent Safe's signature threshold. Contains the delegate address, label, TOTP used, and collected signatures.
- **Collected Signature**: An individual owner's signature on the delegate typed data hash, associated with a pending delegation request.
## Success Criteria _(mandatory)_
### Measurable Outcomes
- **SC-001**: Users with 2/N parent Safes can successfully add a proposer to nested Safes once the threshold of owner signatures is collected.
- **SC-002**: The signature collection flow completes within the TOTP validity window (~2 hours) for parent Safes with up to 5 owners.
- **SC-003**: 100% of completed multi-sig delegations (threshold met) result in the proposer appearing in the nested Safe's proposers list.
- **SC-004**: No regression in the existing 1/1 parent Safe proposer addition flow.
- **SC-005**: Users can clearly see the number of signatures collected versus required at every stage of the process.
## Out of Scope
- Multi-level nested Safe resolution (e.g., Safe A → Safe B → Safe C). Only the direct parent Safe (one level up) is supported for signature collection.
- Active notifications or badges for pending delegation requests outside the proposers settings page.
- Backend/Transaction Service modifications — this feature uses existing off-chain message signing infrastructure as-is.
## Assumptions
- The backend (Safe Transaction Service) already supports EIP-1271 signatures with multiple concatenated owner signatures — no backend changes are needed.
- The Safe contract's `isValidSignature` implementation validates that the concatenated signatures meet the threshold and that each signer is a current owner, with signatures sorted by owner address (ascending).
- Pending delegation requests will be stored via the Safe Transaction Service's off-chain message signing infrastructure, enabling co-owners on different devices/browsers to discover and sign pending requests without shared local state.
- The TOTP window from the backend is approximately 2 hours (current hour ± 1 hour, with chain_id variations).
- The existing `useNestedSafeOwners()` and `useIsNestedSafeOwner()` hooks correctly identify parent Safe relationships and do not need modification.
@@ -0,0 +1,204 @@
# Tasks: Proposer Multisig Validation for 2/N Parent Safes
**Input**: Design documents from `/specs/004-proposer-multisig-validation/`
**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
**Organization**: Tasks are grouped by user story to enable independent implementation and testing of each story.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies)
- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3, US4)
- Include exact file paths in descriptions
---
## Phase 1: Setup (Shared Infrastructure)
**Purpose**: TypeScript interfaces and type definitions used across all user stories
- [x] T001 [P] Create DelegationOrigin and PendingDelegation TypeScript interfaces in apps/web/src/features/proposers/types.ts
- [x] T002 [P] Create TOTP utility functions (getCurrentTotp, isTotpValid) in apps/web/src/features/proposers/utils/totp.ts
---
## Phase 2: Foundational (Blocking Prerequisites)
**Purpose**: Core hooks and services that MUST be complete before ANY user story can be implemented
**CRITICAL**: No user story work can begin until this phase is complete
- [x] T003 Implement useParentSafeThreshold hook that fetches parent Safe threshold and owners via useSafesGetSafeV1Query in apps/web/src/features/proposers/hooks/useParentSafeThreshold.ts
- [x] T004 Implement delegation message service with createDelegationMessage and confirmDelegationMessage functions using RTK Query mutations in apps/web/src/features/proposers/services/delegationMessages.ts
- [x] T005 Implement buildDelegationOrigin helper that creates JSON-encoded origin metadata string in apps/web/src/features/proposers/services/delegationMessages.ts
**Checkpoint**: Foundation ready — user story implementation can now begin
---
## Phase 3: User Story 1 — Add Proposer via 2/N Parent Safe (Priority: P1) MVP
**Goal**: Enable a 2/N parent Safe owner to initiate a proposer addition by creating an off-chain message on the parent Safe, collecting the first signature, and showing a pending state.
**Independent Test**: Connect as a signer of a 2-of-3 parent Safe (which owns a nested Safe), initiate a proposer addition, verify the off-chain message is created on the parent Safe with correct delegate TypedData and origin metadata, and verify the UI shows a pending state with "1 of 2 signatures collected".
### Implementation for User Story 1
- [x] T006 [US1] Modify UpsertProposer.tsx onConfirm handler to check parent Safe threshold before signing: if threshold === 1 use existing flow, if threshold > 1 branch to multi-sig flow in apps/web/src/features/proposers/components/UpsertProposer.tsx
- [x] T007 [US1] Implement multi-sig branch in UpsertProposer: generate delegate TypedData, sign SafeMessage via tryOffChainMsgSigning on parent Safe, create off-chain message via delegationMessages service with DelegationOrigin in apps/web/src/features/proposers/components/UpsertProposer.tsx
- [x] T008 [US1] Add success state in UpsertProposer after multi-sig message creation showing "Signature collection initiated — 1 of N signatures collected" with instructions for other owners in apps/web/src/features/proposers/components/UpsertProposer.tsx
- [x] T009 [US1] Verify encodeEIP1271Signature in utils.ts works with multi-owner preparedSignature (multiple concatenated 65-byte signatures) — add unit test verifying variable-length inner signatures encode correctly in apps/web/src/features/proposers/utils/utils.test.ts
**Checkpoint**: Owner A can initiate a multi-sig proposer delegation and see it stored as an off-chain message on the parent Safe
---
## Phase 4: User Story 2 — Threshold-Aware UX Feedback (Priority: P1)
**Goal**: Clearly communicate to users that adding/removing a proposer requires multiple signatures before they initiate the flow.
**Independent Test**: Connect as a 2/N parent Safe owner, open the "Add proposer" dialog, verify messaging indicates multiple signatures are required before signing.
### Implementation for User Story 2
- [x] T010 [US2] Add threshold-aware messaging to UpsertProposer dialog: when parent Safe threshold > 1, show info alert (MUI Alert component) explaining that N owner signatures are required to complete this delegation in apps/web/src/features/proposers/components/UpsertProposer.tsx
- [x] T011 [US2] Display parent Safe owner count and threshold in the dialog subtitle (e.g., "This requires 2 of 3 parent Safe owner signatures") in apps/web/src/features/proposers/components/UpsertProposer.tsx
**Checkpoint**: Users are informed upfront about the multi-sig requirement before initiating
---
## Phase 5: User Story 4 — Remove Proposer via 2/N Parent Safe (Priority: P1)
**Goal**: Enable a 2/N parent Safe owner to initiate a proposer removal using the same multi-sig signature collection flow as addition.
**Independent Test**: Connect as a 2/N parent Safe owner, initiate proposer removal, verify off-chain message is created with action="remove" and correct delegate TypedData.
### Implementation for User Story 4
- [x] T012 [US4] Modify DeleteProposerDialog.tsx to check parent Safe threshold before signing: if threshold > 1, branch to multi-sig flow (same pattern as UpsertProposer) in apps/web/src/features/proposers/components/DeleteProposerDialog.tsx
- [x] T013 [US4] Implement multi-sig branch in DeleteProposerDialog: generate delegate TypedData, sign SafeMessage, create off-chain message with action="remove" in origin metadata in apps/web/src/features/proposers/components/DeleteProposerDialog.tsx
- [x] T014 [US4] Add threshold-aware messaging and pending state to DeleteProposerDialog (same UX pattern as UpsertProposer) in apps/web/src/features/proposers/components/DeleteProposerDialog.tsx
**Checkpoint**: Both add and remove proposer flows support multi-sig initiation
---
## Phase 6: User Story 3 — Retrieve and Complete Pending Proposer Delegations (Priority: P2)
**Goal**: Enable co-owners to discover pending delegation requests on the proposers settings page, add their signatures, and auto-submit once threshold is met.
**Independent Test**: Have Owner A initiate a delegation, then connect as Owner B of the same parent Safe, navigate to the nested Safe's proposer settings, verify the pending delegation is visible with "1 of 2" progress, sign as Owner B, verify the system auto-submits the completed EIP-1271 signature to the delegate API.
### Implementation for User Story 3
- [x] T015 [US3] Implement usePendingDelegations hook: fetch messages for parent Safe via useMessagesGetMessagesBySafeV1Query, filter by origin.type === "proposer-delegation" and origin.nestedSafe === currentSafe, derive status (pending/ready/expired) using TOTP validation in apps/web/src/features/proposers/hooks/usePendingDelegations.ts
- [x] T016 [US3] Implement useSubmitDelegation hook: take a confirmed PendingDelegation, wrap preparedSignature with encodeEIP1271Signature, call delegate API (V2 add or V2 delete based on action field), invalidate delegates query cache on success in apps/web/src/features/proposers/hooks/useSubmitDelegation.ts
- [x] T017 [US3] Create PendingDelegation component showing: delegate address/label, action (add/remove), signature progress bar (e.g., "1 of 2"), proposedBy address, creation time, status badge (pending/ready/expired), and action buttons in apps/web/src/features/proposers/components/PendingDelegation.tsx
- [x] T018 [US3] Implement "Sign" button in PendingDelegation component: when clicked, sign the SafeMessage (same delegate TypedData from message content) via tryOffChainMsgSigning on parent Safe, call confirmDelegationMessage, refetch messages on success in apps/web/src/features/proposers/components/PendingDelegation.tsx
- [x] T019 [US3] Implement auto-submission logic: after successful confirmation that brings confirmationsSubmitted to equal confirmationsRequired, automatically call useSubmitDelegation with the updated preparedSignature in apps/web/src/features/proposers/components/PendingDelegation.tsx
- [x] T020 [US3] Implement "Submit" button for ready-state delegations: when threshold was met by another user (discovered on page load), show a "Submit delegation" button that triggers useSubmitDelegation in apps/web/src/features/proposers/components/PendingDelegation.tsx
- [x] T021 [US3] Implement expired state display: show "Expired" badge and "Re-initiate" button that opens UpsertProposer/DeleteProposerDialog pre-filled with the same delegate address and label in apps/web/src/features/proposers/components/PendingDelegation.tsx
- [x] T022 [US3] Create PendingDelegationsList component that renders a list of PendingDelegation components, with a section header "Pending Delegations" and empty state when no pending delegations exist in apps/web/src/features/proposers/components/PendingDelegationsList.tsx
- [x] T023 [US3] Integrate PendingDelegationsList into ProposersList settings page: render above the existing proposers list, only visible when parent Safe threshold > 1 and isNestedSafeOwner in apps/web/src/components/settings/ProposersList/index.tsx
**Checkpoint**: Full end-to-end multi-sig delegation flow works — Owner A initiates, Owner B discovers and confirms, delegation auto-submits
---
## Phase 7: Polish & Cross-Cutting Concerns
**Purpose**: Edge cases, error handling, and verification
- [x] T024 [P] Add error handling for off-chain message creation failures (network errors, permission errors) with user-facing error messages in UpsertProposer and DeleteProposerDialog
- [x] T025 [P] Add loading states for async operations: message creation, signature confirmation, delegate API submission in PendingDelegation component
- [x] T026 Verify 1/1 parent Safe regression: ensure existing single-step flow is unchanged when parent threshold === 1 by running existing proposer tests in apps/web/src/features/proposers/utils/utils.test.ts
- [x] T027 Run full quality gate validation: type-check, lint, prettier, and test suite pass
---
## Dependencies & Execution Order
### Phase Dependencies
- **Setup (Phase 1)**: No dependencies — can start immediately
- **Foundational (Phase 2)**: Depends on Phase 1 (uses interfaces from T001)
- **US1 (Phase 3)**: Depends on Foundational — the core MVP
- **US2 (Phase 4)**: Depends on T003 (useParentSafeThreshold) — can run in parallel with US1
- **US4 (Phase 5)**: Depends on Foundational — can run in parallel with US1/US2
- **US3 (Phase 6)**: Depends on US1 completion (needs off-chain messages to exist for discovery)
- **Polish (Phase 7)**: Depends on all user stories being complete
### User Story Dependencies
- **US1 (P1)**: Can start after Foundational (Phase 2)
- **US2 (P1)**: Can start after Foundational — only needs useParentSafeThreshold
- **US4 (P1)**: Can start after Foundational — parallel with US1/US2
- **US3 (P2)**: Depends on US1 being complete (the initiation flow must exist for discovery to work)
### Within Each User Story
- Services/hooks before components
- Core implementation before UX polish
- Story complete before moving to next
### Parallel Opportunities
Phase 1: T001 and T002 can run in parallel (different files)
Phase 3-5: US1, US2, and US4 can run in parallel after Foundational completes (different components, shared hooks are read-only)
Phase 7: T024 and T025 can run in parallel (different components)
---
## Parallel Example: After Foundational
```bash
# These can all start once Phase 2 completes:
Task: "T006 [US1] Modify UpsertProposer.tsx..."
Task: "T010 [US2] Add threshold-aware messaging..."
Task: "T012 [US4] Modify DeleteProposerDialog.tsx..."
```
---
## Implementation Strategy
### MVP First (User Story 1 + 3 minimum)
1. Complete Phase 1: Setup (interfaces)
2. Complete Phase 2: Foundational (hooks, services)
3. Complete Phase 3: US1 (initiate multi-sig add)
4. Complete Phase 6: US3 (discover and complete pending delegations)
5. **STOP and VALIDATE**: Full add-proposer flow works end-to-end with 2/N parent Safe
6. Deploy/demo if ready
### Full Delivery
1. Setup + Foundational → Foundation ready
2. US1 → Owner can initiate multi-sig delegation
3. US2 → Clear UX messaging about multi-sig requirement
4. US4 → Remove proposer also works with multi-sig
5. US3 → Co-owners can discover, sign, and complete delegations
6. Polish → Error handling, loading states, regression verification
7. Quality gates → type-check, lint, test all pass
### Parallel Team Strategy
With 2 developers after Foundational completes:
- Developer A: US1 (add flow) → US3 (discovery/completion)
- Developer B: US4 (remove flow) → US2 (UX messaging) → Polish
---
## Notes
- [P] tasks = different files, no dependencies
- [Story] label maps task to specific user story for traceability
- The existing `encodeEIP1271Signature` function already handles multi-owner signatures (no code change needed per Research Decision 8)
- Parent Safe info is fetched fresh via CGW API (no stale local cache)
- TOTP validity is checked client-side before allowing submission
- `preparedSignature` from Transaction Service is pre-sorted by owner address (no client sorting needed)
- All new components use MUI and theme tokens (no hardcoded styles)