Skip to content

Commit

Permalink
feat: Hooks to update Safe threshold + owners (#13)
Browse files Browse the repository at this point in the history
* Use `useMutation` from `@tanstack/react-query` instead of from `wagmi/query`

* Remove debug log line

* refactor: Update `useSendTransaction` to handle SafeTransaction objects

The useSendTransaction hook now handles both TransactionBase and SafeTransaction objects. It maps the transactions array and converts any SafeTransaction objects to the required format before sending them to the signerClient.

* Add `isOwnerConnected` flag

It defines whether the connected signer is an owner of the configured Safe

* Add `useSignerClientMutation` hook for sending custom mutations via the SafeClient

* Add UpdateThreshold hook for updating the threshold of the connected Safe

* Add 'useAddOwner' hook for adding an owner to the connected Safe

* Add `useRemoveOwner` hook for removing an owner from the connected Safe

* Add `useSwapOwner` hook for swapping an owner of the connected Safe

* Add `useUpdateOwners` hook for managing owners of the connected Safe

It wraps the individual hooks:
- `useAddOwner`
- `useRemoveOwner`
- `useSwapOwner`

* refactor: Update usePendingTransactions to use usePublicClientQuery

Refactor the usePendingTransactions hook to use the usePublicClientQuery hook instead of the usePublicClient hook.

* Unit tests for `useSignerClientMutation` hook

* refactor: Update `useUpdateThreshold` hook to use `useSignerClientMutation`

* Unit tests for `useUpdateThreshold` hook

* refactor: Update `useSendTransaction` hook to use `useSignerClientMutation`

* refactor: Update `useConfirmTransaction` hook to use `useSignerClientMutation`

* Add unit tests for `useUpdateOwners` hook

Also add fixtures for mutation result objects

* Refactor tests to use mutation result object fixtures that were added in previous commit

* Add unit tests for `useRemoveOwner` hook

* Add unit tests for `useSwapOwner` hook

* Fix tests

* Add unit tests for `useUpdateOwners` hook

* refactor: Rename `getCustomMutationResult` to `createCustomMutationResult` for consistency

* refactor: Add `createCustomQueryResult` for consistency with createCustomMutationResult

* refactor: Call functions directly from SafeClient instance, instead of from `SafeClient.protocolKit`

* refactor: Improve useAuthenticate hook

Call `signerClient.isOwner` instead of checking owners array to compute `isOwnerConnected`.

Throw error if not rendered in SafeProvider

* Fix name of `useUpdateOwners` hook's param type

* Improve JSDoc descriptions

* Improve UseTransactionParams

The config param can only be defined in combination with the safeTxHash param.
  • Loading branch information
tmjssz authored Oct 9, 2024
1 parent 6f620f2 commit 6e40ebc
Show file tree
Hide file tree
Showing 43 changed files with 2,160 additions and 488 deletions.
6 changes: 5 additions & 1 deletion src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,9 @@ export enum QueryKey {

export enum MutationKey {
SendTransaction = 'sendTransaction',
ConfirmTransaction = 'confirmTransaction'
ConfirmTransaction = 'confirmTransaction',
UpdateThreshold = 'updateThreshold',
SwapOwner = 'swapOwner',
AddOwner = 'addOwner',
RemoveOwner = 'removeOwner'
}
3 changes: 3 additions & 0 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ export * from './usePendingTransactions.js'
export * from './useSafe.js'
export * from './usePublicClient.js'
export * from './useSafeInfo/index.js'
export * from './useUpdateOwners/index.js'
export * from './useSendTransaction.js'
export * from './useSignerAddress.js'
export * from './useSignerClient.js'
export * from './useTransaction.js'
export * from './useTransactions.js'
export * from './useUpdateThreshold.js'
export * from './useWaitForTransaction.js'
96 changes: 87 additions & 9 deletions src/hooks/useAuthenticate.test.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,156 @@
import { act } from 'react'
import { waitFor } from '@testing-library/react'
import { SafeClient } from '@safe-global/sdk-starter-kit'
import { useAuthenticate } from '@/hooks/useAuthenticate.js'
import { renderHookInMockedSafeProvider } from '@test/utils.js'
import { signerPrivateKeys } from '@test/fixtures/index.js'
import { useAuthenticate, UseConnectSignerReturnType } from '@/hooks/useAuthenticate.js'
import * as useSignerAddress from '@/hooks/useSignerAddress.js'
import { catchHookError, renderHookInMockedSafeProvider } from '@test/utils.js'
import { safeInfo, signerPrivateKeys } from '@test/fixtures/index.js'
import { SafeContextType } from '@/SafeContext.js'
import { configExistingSafe } from '@test/config.js'

describe('useAuthenticate', () => {
const signerClientMock = { safeClient: 'signer' } as unknown as SafeClient
const isOwnerMock = jest.fn()
const signerClientMock = { isOwner: isOwnerMock } as unknown as SafeClient
const setSignerMock = jest.fn(() => Promise.resolve())
const useSignerAddressSpy = jest.spyOn(useSignerAddress, 'useSignerAddress')

const renderUseAuthenticate = async (context: Partial<SafeContextType> = {}) => {
const renderUseAuthenticate = async (
context: Partial<SafeContextType> = {},
expected: Partial<UseConnectSignerReturnType> = {}
) => {
const renderOptions = {
signerClient: signerClientMock,
setSigner: setSignerMock,
config: configExistingSafe,
...context
}

const renderResult = renderHookInMockedSafeProvider(() => useAuthenticate(), renderOptions)
const renderResult = renderHookInMockedSafeProvider(useAuthenticate, renderOptions)

await waitFor(() =>
expect(renderResult.result.current).toEqual({
connect: expect.any(Function),
disconnect: expect.any(Function),
isSignerConnected: !!renderOptions.signerClient
isSignerConnected: false,
isOwnerConnected: false,
...expected
})
)

return renderResult
}

beforeEach(() => {
isOwnerMock.mockResolvedValue(true)
useSignerAddressSpy.mockReturnValue(safeInfo.owners[1])
})

afterEach(() => {
jest.clearAllMocks()
jest.resetAllMocks()
})

describe('isOwnerConnected', () => {
it('should be true if connected signer is not owner of the Safe', async () => {
useSignerAddressSpy.mockReturnValue(safeInfo.owners[0])

const {
result: {
current: { isSignerConnected, isOwnerConnected }
}
} = await renderUseAuthenticate(undefined, {
isSignerConnected: true,
isOwnerConnected: true
})

expect(isSignerConnected).toBeTruthy()
expect(isOwnerConnected).toBeTruthy()
})

it('should be false if connected signer is not owner of the Safe', async () => {
useSignerAddressSpy.mockReturnValueOnce(safeInfo.owners[0])
isOwnerMock.mockResolvedValueOnce(false)

const {
result: {
current: { isSignerConnected, isOwnerConnected }
}
} = await renderUseAuthenticate(undefined, {
isSignerConnected: true
})

expect(isSignerConnected).toBeTruthy()
expect(isOwnerConnected).toBeFalsy()
})
})

describe('connect', () => {
it('should create a new signer client if being called with a valid private key', async () => {
const { result } = await renderUseAuthenticate()
const { result } = await renderUseAuthenticate(undefined, {
isSignerConnected: true,
isOwnerConnected: true
})

await act(() => result.current.connect(signerPrivateKeys[1]))

expect(isOwnerMock).toHaveBeenCalledTimes(1)
expect(useSignerAddressSpy).toHaveBeenCalledTimes(2)

expect(setSignerMock).toHaveBeenCalledTimes(1)
expect(setSignerMock).toHaveBeenCalledWith(signerPrivateKeys[1])
})

it('should throw if being called with an empty private key string', async () => {
useSignerAddressSpy.mockReturnValueOnce(undefined)

const { result } = await renderUseAuthenticate()

expect(() => result.current.connect('')).rejects.toThrow(
'Failed to connect because signer is empty'
)

expect(isOwnerMock).toHaveBeenCalledTimes(0)
expect(useSignerAddressSpy).toHaveBeenCalledTimes(1)

expect(setSignerMock).toHaveBeenCalledTimes(0)
})
})

describe('disconnect', () => {
it('should set signer to `undefined` if connected', async () => {
const { result } = await renderUseAuthenticate()
const { result } = await renderUseAuthenticate(undefined, {
isSignerConnected: true,
isOwnerConnected: true
})

await act(() => result.current.disconnect())

expect(isOwnerMock).toHaveBeenCalledTimes(1)
expect(useSignerAddressSpy).toHaveBeenCalledTimes(2)

expect(setSignerMock).toHaveBeenCalledTimes(1)
expect(setSignerMock).toHaveBeenCalledWith(undefined)
})

it('should throw if being called when signerClient is not defined', async () => {
useSignerAddressSpy.mockReturnValueOnce(undefined)

const { result } = await renderUseAuthenticate({ signerClient: undefined })

expect(() => result.current.disconnect()).rejects.toThrow(
'Failed to disconnect because no signer is connected'
)

expect(isOwnerMock).toHaveBeenCalledTimes(0)
expect(useSignerAddressSpy).toHaveBeenCalledTimes(1)

expect(setSignerMock).toHaveBeenCalledTimes(0)
})
})

it('should throw if not used within a `SafeProvider`', async () => {
const error = catchHookError(() => useAuthenticate())

expect(error?.message).toEqual('`useAuthenticate` must be used within `SafeProvider`.')
})
})
28 changes: 24 additions & 4 deletions src/hooks/useAuthenticate.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,33 @@
import { useCallback, useContext } from 'react'
import { useCallback, useContext, useEffect, useState } from 'react'
import { SafeContext } from '@/SafeContext.js'
import { useSignerAddress } from '@/hooks/useSignerAddress.js'
import { AuthenticationError } from '@/errors/AuthenticationError.js'
import { ConfigParam, SafeConfig } from '@/types/index.js'
import { MissingSafeProviderError } from '@/errors/MissingSafeProviderError.js'

export type UseAuthenticateParams = ConfigParam<SafeConfig>

export type UseConnectSignerReturnType = {
connect: (signer: string) => Promise<void>
disconnect: () => Promise<void>
isSignerConnected: boolean
isOwnerConnected: boolean
}

/**
* Hook to authenticate a signer.
* @returns Functions to connect and disconnect a signer.
*/
export function useAuthenticate(): UseConnectSignerReturnType {
const { signerClient, setSigner } = useContext(SafeContext)
const { signerClient, setSigner, config } = useContext(SafeContext) || {}

if (!config) {
throw new MissingSafeProviderError('`useAuthenticate` must be used within `SafeProvider`.')
}

const signerAddress = useSignerAddress()

const [isOwnerConnected, setIsOwnerConnected] = useState(false)

const connect = useCallback(
async (signer: string) => {
Expand All @@ -32,7 +46,13 @@ export function useAuthenticate(): UseConnectSignerReturnType {
return setSigner(undefined)
}, [setSigner])

const isSignerConnected = !!signerClient
const isSignerConnected = !!signerAddress

useEffect(() => {
if (signerClient && signerAddress) {
signerClient.isOwner(signerAddress).then(setIsOwnerConnected)
}
}, [signerClient, signerAddress])

return { connect, disconnect, isSignerConnected }
return { connect, disconnect, isSignerConnected, isOwnerConnected }
}
Loading

0 comments on commit 6e40ebc

Please sign in to comment.