diff --git a/src/hooks/useContractFunctionHook.test.tsx b/src/hooks/useContractFunctionHook.test.tsx
new file mode 100644
index 0000000..6b8a31a
--- /dev/null
+++ b/src/hooks/useContractFunctionHook.test.tsx
@@ -0,0 +1,367 @@
+import { renderHook, act } from '@testing-library/react'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { useContractFunctionHook } from './useContractFunctionHook'
+
+vi.mock('../components/common/contexts/DocumentContext', () => ({
+ useDocumentContext: () => ({ keyId: 'test-key' }),
+}))
+
+const { mockTransferHolder } = vi.hoisted(() => ({
+ mockTransferHolder: vi.fn(),
+}))
+
+vi.mock('@trustvc/trustvc', () => ({
+ transferHolder: mockTransferHolder,
+ transferBeneficiary: vi.fn(),
+ transferOwners: vi.fn(),
+ rejectTransferHolder: vi.fn(),
+ rejectTransferBeneficiary: vi.fn(),
+ rejectTransferOwners: vi.fn(),
+ nominate: vi.fn(),
+ returnToIssuer: vi.fn(),
+ rejectReturned: vi.fn(),
+ acceptReturned: vi.fn(),
+}))
+
+const mockContract = { address: '0xabc' } as any
+
+describe('useContractFunctionHook', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ })
+
+ describe('send — missing contract or method', () => {
+ it('sets ERROR and errorMessage when contract is undefined', async () => {
+ const { result } = renderHook(() =>
+ useContractFunctionHook(undefined, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe(
+ 'Contract or method is not specified'
+ )
+ })
+
+ it('sets ERROR and errorMessage when method is undefined', async () => {
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, undefined)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe(
+ 'Contract or method is not specified'
+ )
+ })
+ })
+
+ describe('send — MetaMask numeric error codes', () => {
+ it('maps code 4001 to "User Rejected Transaction" and sets ERROR state', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 4001 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe('User Rejected Transaction')
+ })
+
+ it('user rejection now sets ERROR state (not UNINITIALIZED)', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 4001 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ })
+
+ it('maps code 4100 to "Unauthorized: Account or method not authorized"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 4100 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe(
+ 'Unauthorized: Account or method not authorized'
+ )
+ })
+
+ it('maps code 4900 to "Wallet Disconnected"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 4900 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Wallet Disconnected')
+ })
+
+ it('maps code -32603 to "Internal Error"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: -32603 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Internal Error')
+ })
+
+ it('maps code -32000 to "Invalid Input"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: -32000 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Invalid Input')
+ })
+
+ it('maps code -32003 to "Transaction Rejected"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: -32003 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Transaction Rejected')
+ })
+ })
+
+ describe('send — ethers string error codes', () => {
+ it('maps ACTION_REJECTED to "User Rejected Transaction"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 'ACTION_REJECTED' })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe('User Rejected Transaction')
+ })
+
+ it('maps INSUFFICIENT_FUNDS to "Insufficient Funds"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 'INSUFFICIENT_FUNDS' })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Insufficient Funds')
+ })
+
+ it('maps UNPREDICTABLE_GAS_LIMIT to "Unable to Estimate Gas"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({
+ code: 'UNPREDICTABLE_GAS_LIMIT',
+ })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Unable to Estimate Gas')
+ })
+
+ it('maps CALL_EXCEPTION to "Contract Call Failed"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 'CALL_EXCEPTION' })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Contract Call Failed')
+ })
+
+ it('maps NETWORK_ERROR to "Network Error"', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 'NETWORK_ERROR' })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('Network Error')
+ })
+ })
+
+ describe('send — fallback error handling', () => {
+ it('uses error.message when code is not in any map', async () => {
+ mockTransferHolder.mockRejectedValueOnce(
+ new Error('Transaction failed: nonce too low')
+ )
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe(
+ 'Transaction failed: nonce too low'
+ )
+ })
+
+ it('returns empty string for errors with unknown numeric code and no message', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 9999 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('')
+ })
+
+ it('returns empty string for errors with unknown string code and no message', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 'UNKNOWN_CODE' })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('')
+ })
+ })
+
+ describe('send — error thrown inside transaction.wait()', () => {
+ it('applies error mapping to errors thrown during wait()', async () => {
+ const mockWait = vi.fn().mockRejectedValueOnce({ code: 4001 })
+ mockTransferHolder.mockResolvedValueOnce({ wait: mockWait })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe('User Rejected Transaction')
+ })
+ })
+
+ describe('reset', () => {
+ it('clears errorMessage and returns to UNINITIALIZED', async () => {
+ mockTransferHolder.mockRejectedValueOnce({ code: 4001 })
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContract, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.send({})
+ })
+
+ expect(result.current.errorMessage).toBe('User Rejected Transaction')
+
+ act(() => {
+ result.current.reset()
+ })
+
+ expect(result.current.errorMessage).toBeUndefined()
+ expect(result.current.state).toBe('UNINITIALIZED')
+ })
+ })
+
+ describe('call — error handling', () => {
+ it('sets ERROR state without errorMessage when contract is missing', async () => {
+ const { result } = renderHook(() =>
+ useContractFunctionHook(undefined, 'transferHolder' as any)
+ )
+
+ await act(async () => {
+ await result.current.call()
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBeUndefined()
+ })
+
+ it('maps call error code to errorMessage', async () => {
+ const mockContractWithMethod = {
+ functions: {
+ someMethod: vi
+ .fn()
+ .mockRejectedValueOnce({ code: 'INSUFFICIENT_FUNDS' }),
+ },
+ } as any
+
+ const { result } = renderHook(() =>
+ useContractFunctionHook(mockContractWithMethod, 'someMethod' as any)
+ )
+
+ await act(async () => {
+ await result.current.call()
+ })
+
+ expect(result.current.state).toBe('ERROR')
+ expect(result.current.errorMessage).toBe('Insufficient Funds')
+ })
+ })
+})
diff --git a/src/hooks/useContractFunctionHook.tsx b/src/hooks/useContractFunctionHook.tsx
index cac9701..9a4ef69 100644
--- a/src/hooks/useContractFunctionHook.tsx
+++ b/src/hooks/useContractFunctionHook.tsx
@@ -16,6 +16,49 @@ import { TitleEscrow, TradeTrustToken } from '../types'
import { TypedContractMethod } from '@trustvc/trustvc'
import { useDocumentContext } from '../components/common/contexts/DocumentContext'
+const METAMASK_NUMERIC_CODES: Record
= {
+ 4001: 'User Rejected Transaction',
+ 4100: 'Unauthorized: Account or method not authorized',
+ 4200: 'Unsupported Method',
+ 4900: 'Wallet Disconnected',
+ 4901: 'Chain Disconnected',
+ [-32700]: 'Parse Error',
+ [-32600]: 'Invalid Request',
+ [-32601]: 'Method Not Found',
+ [-32602]: 'Invalid Parameters',
+ [-32603]: 'Internal Error',
+ [-32000]: 'Invalid Input',
+ [-32001]: 'Resource Not Found',
+ [-32002]: 'Request Already Pending',
+ [-32003]: 'Transaction Rejected',
+ [-32004]: 'Method Not Supported',
+ [-32005]: 'Request Limit Exceeded',
+}
+
+const ETHERS_STRING_CODES: Record = {
+ ACTION_REJECTED: 'User Rejected Transaction',
+ INSUFFICIENT_FUNDS: 'Insufficient Funds',
+ UNPREDICTABLE_GAS_LIMIT: 'Unable to Estimate Gas',
+ NETWORK_ERROR: 'Network Error',
+ SERVER_ERROR: 'Server Error',
+ TIMEOUT: 'Request Timed Out',
+ CALL_EXCEPTION: 'Contract Call Failed',
+ TRANSACTION_REPLACED: 'Transaction Replaced',
+ NONCE_EXPIRED: 'Nonce Already Used',
+ REPLACEMENT_UNDERPRICED: 'Replacement Transaction Underpriced',
+}
+
+const getMetaMaskErrorMessage = (e: unknown): string => {
+ const code = (e as any)?.code
+ if (typeof code === 'number' && code in METAMASK_NUMERIC_CODES) {
+ return METAMASK_NUMERIC_CODES[code]
+ }
+ if (typeof code === 'string' && code in ETHERS_STRING_CODES) {
+ return ETHERS_STRING_CODES[code]
+ }
+ return (e as Error)?.message || ''
+}
+
// Create a mapping of method names to trustvc functions
const trustvcFunctions: Record any> = {
transferHolder,
@@ -68,18 +111,17 @@ export function useContractFunctionHook<
state: ContractFunctionState
receipt?: ContractReceipt
transaction?: ContractTransaction
- error?: Error
+ errorMessage?: string
value?: UnwrapPromise<
ReturnType any ? T[S] : never>
>
events?: ContractReceipt['events']
transactionHash?: string
- errorMessage?: string
} {
const [state, setState] = useState('UNINITIALIZED')
const [receipt, setReceipt] = useState()
const [transaction, setTransaction] = useState()
- const [error, setError] = useState()
+ const [errorMessage, setErrorMessage] = useState()
const [value, setValue] =
useState<
UnwrapPromise<
@@ -93,7 +135,7 @@ export function useContractFunctionHook<
setState('UNINITIALIZED')
setReceipt(undefined)
setTransaction(undefined)
- setError(undefined)
+ setErrorMessage(undefined)
setValue(undefined)
}
@@ -101,7 +143,7 @@ export function useContractFunctionHook<
const sendFn = (async (params: any) => {
if (!contract || !method) {
setState('ERROR')
- setError(new Error('Contract or method is not specified'))
+ setErrorMessage('Contract or method is not specified')
return
}
resetState()
@@ -135,11 +177,7 @@ export function useContractFunctionHook<
setState('CONFIRMED')
setReceipt(_receipt)
} catch (e) {
- if ((e as Error).message?.includes('user rejected transaction')) {
- setState('UNINITIALIZED')
- return
- }
- setError(e as Error)
+ setErrorMessage(getMetaMaskErrorMessage(e))
setState('ERROR')
}
}) as TypedContractMethod<
@@ -151,7 +189,6 @@ export function useContractFunctionHook<
const callFn = (async (...params: any[]) => {
if (!contract || !method) {
setState('ERROR')
- setError(new Error('Contract or method is not specified'))
return
}
resetState()
@@ -167,7 +204,7 @@ export function useContractFunctionHook<
setState('CONFIRMED')
setValue(response)
} catch (e) {
- setError(e as Error)
+ setErrorMessage(getMetaMaskErrorMessage(e))
setState('ERROR')
}
}) as TypedContractMethod<
@@ -178,7 +215,6 @@ export function useContractFunctionHook<
const transactionHash = transaction?.hash
const events = receipt?.events
- const errorMessage = error?.message
// eslint-disable-next-line react-hooks/exhaustive-deps
const send = useCallback(sendFn, [
@@ -202,7 +238,6 @@ export function useContractFunctionHook<
transaction,
transactionHash,
errorMessage,
- error,
value,
reset,
}