diff --git a/.changeset/dry-actors-bathe.md b/.changeset/dry-actors-bathe.md new file mode 100644 index 0000000000..e371277d6c --- /dev/null +++ b/.changeset/dry-actors-bathe.md @@ -0,0 +1,5 @@ +--- +"@coinbase/onchainkit": patch +--- + +**feat**: add `useSendCall` and `useSendCalls` hooks to support call-type transactions in `Transaction` component by @0xAlec #1130 diff --git a/src/transaction/hooks/useSendCall.test.ts b/src/transaction/hooks/useSendCall.test.ts new file mode 100644 index 0000000000..bef4a1c47f --- /dev/null +++ b/src/transaction/hooks/useSendCall.test.ts @@ -0,0 +1,188 @@ +import { renderHook } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSendTransaction as useSendCallWagmi } from 'wagmi'; +import { GENERIC_ERROR_MESSAGE } from '../constants'; +import { isUserRejectedRequestError } from '../utils/isUserRejectedRequestError'; +import { useSendCall } from './useSendCall'; + +vi.mock('wagmi', () => ({ + useSendTransaction: vi.fn(), +})); + +vi.mock('../utils/isUserRejectedRequestError', () => ({ + isUserRejectedRequestError: vi.fn(), +})); + +type UseSendCallConfig = { + mutation: { + onError: (error: Error) => void; + onSuccess: (hash: string) => void; + }; +}; + +type MockUseSendCallReturn = { + status: 'idle' | 'error' | 'loading' | 'success'; + sendCallAsync: ReturnType; + data: string | null; +}; + +describe('useSendCall', () => { + const mockSetLifeCycleStatus = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return wagmi hook data when successful', () => { + const mockSendTransaction = vi.fn(); + const mockData = 'mockTransactionHash'; + (useSendCallWagmi as ReturnType).mockReturnValue({ + status: 'idle', + sendTransactionAsync: mockSendTransaction, + data: mockData, + }); + const { result } = renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [], + }), + ); + expect(result.current.status).toBe('idle'); + expect(result.current.sendCallAsync).toBe(mockSendTransaction); + expect(result.current.data).toBe(mockData); + }); + + it('should handle generic error', () => { + const genericError = new Error(GENERIC_ERROR_MESSAGE); + let onErrorCallback: ((error: Error) => void) | undefined; + (useSendCallWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallConfig) => { + onErrorCallback = mutation.onError; + return { + sendCallAsync: vi.fn(), + data: null, + status: 'error', + } as MockUseSendCallReturn; + }, + ); + renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [], + }), + ); + expect(onErrorCallback).toBeDefined(); + onErrorCallback?.(genericError); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: { + code: 'TmUSCh01', + error: GENERIC_ERROR_MESSAGE, + message: GENERIC_ERROR_MESSAGE, + }, + }); + }); + + it('should handle user rejected error', () => { + const userRejectedError = new Error('Request denied.'); + let onErrorCallback: ((error: Error) => void) | undefined; + (useSendCallWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallConfig) => { + onErrorCallback = mutation.onError; + return { + sendCallAsync: vi.fn(), + data: null, + status: 'error', + } as MockUseSendCallReturn; + }, + ); + (isUserRejectedRequestError as vi.Mock).mockReturnValue(true); + renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [], + }), + ); + expect(onErrorCallback).toBeDefined(); + onErrorCallback?.(userRejectedError); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: { + code: 'TmUSCh01', + error: 'Request denied.', + message: 'Request denied.', + }, + }); + }); + + it('should handle successful transaction', () => { + const transactionHash = '0x123456'; + let onSuccessCallback: ((hash: string) => void) | undefined; + (useSendCallWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallConfig) => { + onSuccessCallback = mutation.onSuccess; + return { + sendCallAsync: vi.fn(), + data: transactionHash, + status: 'success', + } as MockUseSendCallReturn; + }, + ); + renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [], + }), + ); + expect(onSuccessCallback).toBeDefined(); + onSuccessCallback?.(transactionHash); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'transactionLegacyExecuted', + statusData: { + transactionHashList: [transactionHash], + }, + }); + }); + + it('should handle multiple successful transactions', () => { + const transactionHash = '0x12345678'; + let onSuccessCallback: ((hash: string) => void) | undefined; + (useSendCallWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallConfig) => { + onSuccessCallback = mutation.onSuccess; + return { + sendCallAsync: vi.fn(), + data: transactionHash, + status: 'success', + } as MockUseSendCallReturn; + }, + ); + renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [], + }), + ); + expect(onSuccessCallback).toBeDefined(); + onSuccessCallback?.(transactionHash); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'transactionLegacyExecuted', + statusData: { + transactionHashList: [transactionHash], + }, + }); + renderHook(() => + useSendCall({ + setLifeCycleStatus: mockSetLifeCycleStatus, + transactionHashList: [transactionHash], + }), + ); + onSuccessCallback?.(transactionHash); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'transactionLegacyExecuted', + statusData: { + transactionHashList: [transactionHash, transactionHash], + }, + }); + }); +}); diff --git a/src/transaction/hooks/useSendCall.ts b/src/transaction/hooks/useSendCall.ts new file mode 100644 index 0000000000..be245409ed --- /dev/null +++ b/src/transaction/hooks/useSendCall.ts @@ -0,0 +1,46 @@ +import type { Address } from 'viem'; +import { useSendTransaction as useSendCallWagmi } from 'wagmi'; +import { GENERIC_ERROR_MESSAGE } from '../constants'; +import type { UseSendCallParams } from '../types'; +import { isUserRejectedRequestError } from '../utils/isUserRejectedRequestError'; + +/** + * Wagmi hook for single transactions with calldata. + * Supports both EOAs and Smart Wallets. + * Does not support transaction batching or paymasters. + */ +export function useSendCall({ + setLifeCycleStatus, + transactionHashList, +}: UseSendCallParams) { + const { + status, + sendTransactionAsync: sendCallAsync, + data, + } = useSendCallWagmi({ + mutation: { + onError: (e) => { + const errorMessage = isUserRejectedRequestError(e) + ? 'Request denied.' + : GENERIC_ERROR_MESSAGE; + setLifeCycleStatus({ + statusName: 'error', + statusData: { + code: 'TmUSCh01', // Transaction module UseSendCall hook 01 error + error: e.message, + message: errorMessage, + }, + }); + }, + onSuccess: (hash: Address) => { + setLifeCycleStatus({ + statusName: 'transactionLegacyExecuted', + statusData: { + transactionHashList: [...transactionHashList, hash], + }, + }); + }, + }, + }); + return { status, sendCallAsync, data }; +} diff --git a/src/transaction/hooks/useSendCalls.test.ts b/src/transaction/hooks/useSendCalls.test.ts new file mode 100644 index 0000000000..dba429e993 --- /dev/null +++ b/src/transaction/hooks/useSendCalls.test.ts @@ -0,0 +1,143 @@ +import { renderHook } from '@testing-library/react'; +import type { TransactionExecutionError } from 'viem'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { useSendCalls as useSendCallsWagmi } from 'wagmi/experimental'; +import { GENERIC_ERROR_MESSAGE } from '../constants'; +import { isUserRejectedRequestError } from '../utils/isUserRejectedRequestError'; +import { useSendCalls } from './useSendCalls'; + +vi.mock('wagmi/experimental', () => ({ + useSendCalls: vi.fn(), +})); + +vi.mock('../utils/isUserRejectedRequestError', () => ({ + isUserRejectedRequestError: vi.fn(), +})); + +type UseSendCallsConfig = { + mutation: { + onSettled: () => void; + onError: (error: TransactionExecutionError) => void; + onSuccess: (id: string) => void; + }; +}; + +describe('useSendCalls', () => { + const mockSetLifeCycleStatus = vi.fn(); + const mockSetTransactionId = vi.fn(); + + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return wagmi hook data when successful', () => { + const mockSendCallsAsync = vi.fn(); + const mockData = 'mockTransactionId'; + (useSendCallsWagmi as ReturnType).mockReturnValue({ + status: 'idle', + sendCallsAsync: mockSendCallsAsync, + data: mockData, + }); + const { result } = renderHook(() => + useSendCalls({ + setLifeCycleStatus: mockSetLifeCycleStatus, + setTransactionId: mockSetTransactionId, + }), + ); + expect(result.current.status).toBe('idle'); + expect(result.current.sendCallsAsync).toBe(mockSendCallsAsync); + expect(result.current.data).toBe(mockData); + }); + + it('should handle generic error', () => { + const genericError = new Error(GENERIC_ERROR_MESSAGE); + let onErrorCallback: + | ((error: TransactionExecutionError) => void) + | undefined; + (useSendCallsWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallsConfig) => { + onErrorCallback = mutation.onError; + return { + sendCallsAsync: vi.fn(), + data: null, + status: 'error', + }; + }, + ); + (isUserRejectedRequestError as vi.Mock).mockReturnValue(false); + renderHook(() => + useSendCalls({ + setLifeCycleStatus: mockSetLifeCycleStatus, + setTransactionId: mockSetTransactionId, + }), + ); + expect(onErrorCallback).toBeDefined(); + onErrorCallback?.(genericError as TransactionExecutionError); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: { + code: 'TmUSCSh01', + error: GENERIC_ERROR_MESSAGE, + message: GENERIC_ERROR_MESSAGE, + }, + }); + }); + + it('should handle user rejected error', () => { + const userRejectedError = new Error('Request denied.'); + let onErrorCallback: + | ((error: TransactionExecutionError) => void) + | undefined; + (useSendCallsWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallsConfig) => { + onErrorCallback = mutation.onError; + return { + sendCallsAsync: vi.fn(), + data: null, + status: 'error', + }; + }, + ); + (isUserRejectedRequestError as vi.Mock).mockReturnValue(true); + renderHook(() => + useSendCalls({ + setLifeCycleStatus: mockSetLifeCycleStatus, + setTransactionId: mockSetTransactionId, + }), + ); + expect(onErrorCallback).toBeDefined(); + onErrorCallback?.(userRejectedError as TransactionExecutionError); + expect(mockSetLifeCycleStatus).toHaveBeenCalledWith({ + statusName: 'error', + statusData: { + code: 'TmUSCSh01', + error: 'Request denied.', + message: 'Request denied.', + }, + }); + }); + + it('should handle successful transaction', () => { + const transactionId = '0x123456'; + let onSuccessCallback: ((id: string) => void) | undefined; + (useSendCallsWagmi as ReturnType).mockImplementation( + ({ mutation }: UseSendCallsConfig) => { + onSuccessCallback = mutation.onSuccess; + return { + sendCallsAsync: vi.fn(), + data: transactionId, + status: 'success', + }; + }, + ); + renderHook(() => + useSendCalls({ + setLifeCycleStatus: mockSetLifeCycleStatus, + setTransactionId: mockSetTransactionId, + }), + ); + expect(onSuccessCallback).toBeDefined(); + onSuccessCallback?.(transactionId); + expect(mockSetTransactionId).toHaveBeenCalledWith(transactionId); + }); +}); diff --git a/src/transaction/hooks/useSendCalls.ts b/src/transaction/hooks/useSendCalls.ts new file mode 100644 index 0000000000..71b14a4480 --- /dev/null +++ b/src/transaction/hooks/useSendCalls.ts @@ -0,0 +1,37 @@ +import { useSendCalls as useSendCallsWagmi } from 'wagmi/experimental'; +import { GENERIC_ERROR_MESSAGE } from '../constants'; +import type { UseSendCallsParams } from '../types'; +import { isUserRejectedRequestError } from '../utils/isUserRejectedRequestError'; + +/** + * useSendCalls: Experimental Wagmi hook for batching transactions with calldata. + * Supports Smart Wallets. + * Supports batch operations and capabilities such as paymasters. + * Does not support EOAs. + */ +export function useSendCalls({ + setLifeCycleStatus, + setTransactionId, +}: UseSendCallsParams) { + const { status, sendCallsAsync, data } = useSendCallsWagmi({ + mutation: { + onError: (e) => { + const errorMessage = isUserRejectedRequestError(e) + ? 'Request denied.' + : GENERIC_ERROR_MESSAGE; + setLifeCycleStatus({ + statusName: 'error', + statusData: { + code: 'TmUSCSh01', // Transaction module UseSendCalls hook 01 error + error: e.message, + message: errorMessage, + }, + }); + }, + onSuccess: (id) => { + setTransactionId(id); + }, + }, + }); + return { status, sendCallsAsync, data }; +} diff --git a/src/transaction/types.ts b/src/transaction/types.ts index 766594c69b..93df4df12c 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -200,6 +200,16 @@ export type UseWriteContractsParams = { setTransactionId: (id: string) => void; }; +export type UseSendCallParams = { + setLifeCycleStatus: (state: LifeCycleStatus) => void; + transactionHashList: Address[]; +}; + +export type UseSendCallsParams = { + setLifeCycleStatus: (state: LifeCycleStatus) => void; + setTransactionId: (id: string) => void; +}; + /** * Note: exported as public Type *