From 8c6b4e3a110478fc5ed0d1bc20373dca922f3b85 Mon Sep 17 00:00:00 2001 From: alec <93971719+0xAlec@users.noreply.github.com> Date: Tue, 10 Sep 2024 10:58:08 -0400 Subject: [PATCH] feat: `Transaction` component - add `calls` support (#1220) --- .changeset/stupid-goats-hope.md | 5 + .../components/AppProvider.tsx | 22 +++ .../nextjs-app-router/components/Demo.tsx | 2 + .../components/demo/Transaction.tsx | 29 ++- .../components/form/transaction-options.tsx | 43 +++++ .../components/transaction/Click.tsx | 4 - .../nextjs-app-router/lib/transactions.ts | 26 +++ src/transaction/components/Transaction.tsx | 2 + .../components/TransactionButton.test.tsx | 8 +- .../components/TransactionButton.tsx | 4 +- .../components/TransactionProvider.test.tsx | 182 ++++++++---------- .../components/TransactionProvider.tsx | 153 +++++++++------ src/transaction/constants.ts | 2 + .../hooks/useSendWalletTransactions.test.tsx | 88 +++++++++ .../hooks/useSendWalletTransactions.tsx | 49 +++++ src/transaction/types.ts | 69 ++++++- .../utils/sendBatchedTransactions.test.ts | 71 +++++++ .../utils/sendBatchedTransactions.ts | 29 +++ .../utils/sendSingleTransactions.test.ts | 71 +++++++ .../utils/sendSingleTransactions.ts | 18 ++ 20 files changed, 698 insertions(+), 179 deletions(-) create mode 100644 .changeset/stupid-goats-hope.md create mode 100644 playground/nextjs-app-router/components/form/transaction-options.tsx create mode 100644 src/transaction/hooks/useSendWalletTransactions.test.tsx create mode 100644 src/transaction/hooks/useSendWalletTransactions.tsx create mode 100644 src/transaction/utils/sendBatchedTransactions.test.ts create mode 100644 src/transaction/utils/sendBatchedTransactions.ts create mode 100644 src/transaction/utils/sendSingleTransactions.test.ts create mode 100644 src/transaction/utils/sendSingleTransactions.ts diff --git a/.changeset/stupid-goats-hope.md b/.changeset/stupid-goats-hope.md new file mode 100644 index 0000000000..9d08c33043 --- /dev/null +++ b/.changeset/stupid-goats-hope.md @@ -0,0 +1,5 @@ +--- +'@coinbase/onchainkit': patch +--- + +**feat**: `Transaction` component - added calls support. by @0xAlec #1220 diff --git a/playground/nextjs-app-router/components/AppProvider.tsx b/playground/nextjs-app-router/components/AppProvider.tsx index a7f3966637..0072acb594 100644 --- a/playground/nextjs-app-router/components/AppProvider.tsx +++ b/playground/nextjs-app-router/components/AppProvider.tsx @@ -11,6 +11,12 @@ export enum OnchainKitComponent { Transaction = 'transaction', Wallet = 'wallet', } + +export enum TransactionTypes { + Calls = 'calls', + Contracts = 'contracts', +} + export type Paymaster = { url: string; enabled: boolean; @@ -23,6 +29,8 @@ type State = { clearWalletType?: () => void; chainId?: number; setChainId?: (chainId: number) => void; + transactionType?: TransactionTypes; + setTransactionType?: (transactionType: TransactionTypes) => void; paymasters?: Record; // paymasters is per network setPaymaster?: (chainId: number, url: string, enabled: boolean) => void; }; @@ -42,6 +50,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { useState(); const [walletType, setWalletTypeState] = useState(); const [chainId, setChainIdState] = useState(); + const [transactionType, setTransactionTypeState] = useState( + TransactionTypes.Contracts, + ); const [paymasters, setPaymastersState] = useState>(); @@ -51,6 +62,7 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { const storedWalletType = localStorage.getItem('walletType'); const storedChainId = localStorage.getItem('chainId'); const storedPaymasters = localStorage.getItem('paymasters'); + const storedTransactionType = localStorage.getItem('transactionType'); if (storedActiveComponent) { setActiveComponent(storedActiveComponent as OnchainKitComponent); @@ -64,6 +76,9 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { if (storedPaymasters) { setPaymastersState(JSON.parse(storedPaymasters)); } + if (storedTransactionType) { + setTransactionTypeState(storedTransactionType as TransactionTypes); + } }, []); // Connect to wallet if walletType changes @@ -106,6 +121,11 @@ export const AppProvider = ({ children }: { children: React.ReactNode }) => { setPaymastersState(newObj); }; + const setTransactionType = (transactionType: TransactionTypes) => { + localStorage.setItem('transactionType', transactionType.toString()); + setTransactionTypeState(transactionType); + }; + return ( { setChainId, paymasters, setPaymaster, + transactionType, + setTransactionType, }} > {children} diff --git a/playground/nextjs-app-router/components/Demo.tsx b/playground/nextjs-app-router/components/Demo.tsx index 9998a1c036..f8034924fc 100644 --- a/playground/nextjs-app-router/components/Demo.tsx +++ b/playground/nextjs-app-router/components/Demo.tsx @@ -9,6 +9,7 @@ import SwapDemo from './demo/Swap'; import TransactionDemo from './demo/Transaction'; import WalletDemo from './demo/Wallet'; import { ActiveComponent } from './form/active-component'; +import { TransactionOptions } from './form/transaction-options'; // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: TODO Refactor this component function Demo() { @@ -46,6 +47,7 @@ function Demo() { + { console.log('Playground.Transaction.chainId:', chainId); }, [chainId]); @@ -29,16 +27,29 @@ function TransactionDemo() { console.log('Playground.Transaction.onStatus:', status); }, []); + useEffect(() => { + console.log('Playground.Transaction.transactionType:', transactionType); + if (transactionType === TransactionTypes.Calls) { + console.log('Playground.Transaction.calls:', calls); + } else { + console.log('Playground.Transaction.contracts:', contracts); + } + }, [transactionType, calls, contracts]); + return (
- + diff --git a/playground/nextjs-app-router/components/form/transaction-options.tsx b/playground/nextjs-app-router/components/form/transaction-options.tsx new file mode 100644 index 0000000000..aab7e42ebc --- /dev/null +++ b/playground/nextjs-app-router/components/form/transaction-options.tsx @@ -0,0 +1,43 @@ +import { Label } from '@/components/ui/label'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { useContext } from 'react'; +import { + AppContext, + OnchainKitComponent, + TransactionTypes, +} from '../AppProvider'; + +export function TransactionOptions() { + const { activeComponent, transactionType, setTransactionType } = + useContext(AppContext); + + return ( + activeComponent === OnchainKitComponent.Transaction && ( +
+ + +
+ ) + ); +} diff --git a/playground/nextjs-app-router/components/transaction/Click.tsx b/playground/nextjs-app-router/components/transaction/Click.tsx index bf19febb2a..b2ad5ce415 100644 --- a/playground/nextjs-app-router/components/transaction/Click.tsx +++ b/playground/nextjs-app-router/components/transaction/Click.tsx @@ -13,13 +13,10 @@ import { TransactionToastLabel, } from '@coinbase/onchainkit/transaction'; import { useContext } from 'react'; -import type { Address } from 'viem'; -import { useAccount } from 'wagmi'; import { AppContext } from '../AppProvider'; export function Click() { const { chainId } = useContext(AppContext); - const account = useAccount(); const capabilities = useCapabilities(); const contracts = clickContracts; console.log('Transaction.click.chainId:', chainId); @@ -27,7 +24,6 @@ export function Click() { return ( diff --git a/playground/nextjs-app-router/lib/transactions.ts b/playground/nextjs-app-router/lib/transactions.ts index 29b9ea5781..999401e9f8 100644 --- a/playground/nextjs-app-router/lib/transactions.ts +++ b/playground/nextjs-app-router/lib/transactions.ts @@ -1,3 +1,4 @@ +import { encodeFunctionData } from 'viem'; import { clickAbi } from './abi/Click'; import { deployedContracts } from './constants'; @@ -8,4 +9,29 @@ export const clickContracts = [ functionName: 'click', args: [], }, + { + address: deployedContracts[85432].click, + abi: clickAbi, + functionName: 'click', + args: [], + }, +]; + +export const clickCalls = [ + { + data: encodeFunctionData({ + abi: clickAbi, + functionName: 'click', + args: [], + }), + to: deployedContracts[85432].click, + }, + { + data: encodeFunctionData({ + abi: clickAbi, + functionName: 'click', + args: [], + }), + to: deployedContracts[85432].click, + }, ]; diff --git a/src/transaction/components/Transaction.tsx b/src/transaction/components/Transaction.tsx index e1b0194c1b..65ac3d69ac 100644 --- a/src/transaction/components/Transaction.tsx +++ b/src/transaction/components/Transaction.tsx @@ -4,6 +4,7 @@ import type { TransactionReact } from '../types'; import { TransactionProvider } from './TransactionProvider'; export function Transaction({ + calls, capabilities, chainId, className, @@ -21,6 +22,7 @@ export function Transaction({ return ( { expect(button).toBeDisabled(); }); - it('should have disabled when contracts are missing', () => { + it('should have disabled when transactions are missing', () => { (useTransactionContext as vi.Mock).mockReturnValue({ - contracts: undefined, + transactions: undefined, lifeCycleStatus: { statusName: 'init', statusData: null }, }); const { getByRole } = render(); @@ -118,9 +118,9 @@ describe('TransactionButton', () => { it('should enable button when not in progress, not missing props, and not waiting for receipt', () => { (useTransactionContext as vi.Mock).mockReturnValue({ - contracts: {}, isLoading: false, lifeCycleStatus: { statusName: 'init', statusData: null }, + transactions: [], transactionId: undefined, transactionHash: undefined, receipt: undefined, @@ -157,7 +157,7 @@ describe('TransactionButton', () => { const onSubmit = vi.fn(); (useTransactionContext as vi.Mock).mockReturnValue({ address: '123', - contracts: [{}], + transactions: [{}], lifeCycleStatus: { statusName: 'init', statusData: null }, onSubmit, receipt: undefined, diff --git a/src/transaction/components/TransactionButton.tsx b/src/transaction/components/TransactionButton.tsx index f3948c2a38..18fc6efa59 100644 --- a/src/transaction/components/TransactionButton.tsx +++ b/src/transaction/components/TransactionButton.tsx @@ -14,13 +14,13 @@ export function TransactionButton({ text: buttonText = 'Transact', }: TransactionButtonReact) { const { - contracts, chainId, errorMessage, isLoading, lifeCycleStatus, onSubmit, receipt, + transactions, transactionHash, transactionId, } = useTransactionContext(); @@ -32,7 +32,7 @@ export function TransactionButton({ const isInProgress = lifeCycleStatus.statusName === 'transactionPending' || isLoading; - const isMissingProps = !contracts || !address; + const isMissingProps = !transactions || !address; const isWaitingForReceipt = !!transactionId || !!transactionHash; const isDisabled = diff --git a/src/transaction/components/TransactionProvider.test.tsx b/src/transaction/components/TransactionProvider.test.tsx index 9f3d41781f..f5566b1ec9 100644 --- a/src/transaction/components/TransactionProvider.test.tsx +++ b/src/transaction/components/TransactionProvider.test.tsx @@ -7,8 +7,10 @@ import { } from 'wagmi'; import { waitForTransactionReceipt } from 'wagmi/actions'; import { useOnchainKit } from '../../useOnchainKit'; -import { METHOD_NOT_SUPPORTED_ERROR_SUBSTRING } from '../constants'; import { useCallsStatus } from '../hooks/useCallsStatus'; +import { useSendCall } from '../hooks/useSendCall'; +import { useSendCalls } from '../hooks/useSendCalls'; +import { useSendWalletTransactions } from '../hooks/useSendWalletTransactions'; import { useWriteContract } from '../hooks/useWriteContract'; import { useWriteContracts } from '../hooks/useWriteContracts'; import { @@ -41,10 +43,29 @@ vi.mock('../hooks/useWriteContracts', () => ({ genericErrorMessage: 'Something went wrong. Please try again.', })); +vi.mock('../hooks/useSendCall', () => ({ + useSendCall: vi.fn(), +})); + +vi.mock('../hooks/useSendCalls', () => ({ + useSendCalls: vi.fn(), +})); + +vi.mock('../hooks/useSendWalletTransactions', () => ({ + useSendWalletTransactions: vi.fn(), +})); + vi.mock('../../useOnchainKit', () => ({ useOnchainKit: vi.fn(), })); +const silenceError = () => { + const consoleErrorMock = vi + .spyOn(console, 'error') + .mockImplementation(() => {}); + return () => consoleErrorMock.mockRestore(); +}; + const TestComponent = () => { const context = useTransactionContext(); const handleStatusError = async () => { @@ -72,6 +93,9 @@ const TestComponent = () => { return (
+ + {JSON.stringify(context.transactions)} + @@ -121,6 +145,14 @@ describe('TransactionProvider', () => { status: 'idle', writeContractsAsync: vi.fn(), }); + (useSendCall as ReturnType).mockReturnValue({ + status: 'idle', + sendCallAsync: vi.fn(), + }); + (useSendCalls as ReturnType).mockReturnValue({ + status: 'idle', + sendCallsAsync: vi.fn(), + }); (useWaitForTransactionReceipt as ReturnType).mockReturnValue({ receipt: undefined, }); @@ -261,6 +293,11 @@ describe('TransactionProvider', () => { status: 'pending', writeContractsAsync: writeContractsAsyncMock, }); + (useOnchainKit as ReturnType).mockReturnValue({ + walletCapabilities: { + hasAtomicBatch: true, + }, + }); render( @@ -298,25 +335,24 @@ describe('TransactionProvider', () => { }); it('should update context on handleSubmit', async () => { - const writeContractsAsyncMock = vi.fn(); - (useWriteContracts as ReturnType).mockReturnValue({ - status: 'idle', - writeContractsAsync: writeContractsAsyncMock, - }); + const sendWalletTransactionsMock = vi.fn(); (useOnchainKit as ReturnType).mockReturnValue({ walletCapabilities: { hasAtomicBatch: true, }, }); + (useSendWalletTransactions as ReturnType).mockReturnValue( + sendWalletTransactionsMock, + ); render( - + , ); const button = screen.getByText('Submit'); fireEvent.click(button); await waitFor(() => { - expect(writeContractsAsyncMock).toHaveBeenCalled(); + expect(sendWalletTransactionsMock).toHaveBeenCalled(); }); }); @@ -389,10 +425,16 @@ describe('TransactionProvider', () => { const writeContractsAsyncMock = vi .fn() .mockRejectedValue({ cause: { name: 'UserRejectedRequestError' } }); + const sendWalletTransactionsMock = vi.fn().mockRejectedValue({ + cause: { name: 'UserRejectedRequestError' }, + }); (useWriteContracts as ReturnType).mockReturnValue({ status: 'idle', writeContractsAsync: writeContractsAsyncMock, }); + (useSendWalletTransactions as ReturnType).mockReturnValue( + sendWalletTransactionsMock, + ); (useOnchainKit as ReturnType).mockReturnValue({ walletCapabilities: { hasAtomicBatch: true, @@ -429,118 +471,58 @@ describe('TransactionProvider', () => { }); }); - it('should call fallbackToWriteContract when executeContracts fails', async () => { - const writeContractsAsyncMock = vi - .fn() - .mockRejectedValue(new Error(METHOD_NOT_SUPPORTED_ERROR_SUBSTRING)); - const writeContractAsyncMock = vi.fn(); - (useWriteContracts as ReturnType).mockReturnValue({ - status: 'idle', - writeContractsAsync: writeContractsAsyncMock, - }); - (useWriteContract as ReturnType).mockReturnValue({ - status: 'idle', - writeContractAsync: writeContractAsyncMock, - }); + it('should set transactions based on contracts', async () => { + const contracts = [{ address: '0x123', method: 'method' }]; render( - + , ); - const button = screen.getByText('Submit'); - fireEvent.click(button); await waitFor(() => { - expect(writeContractAsyncMock).toHaveBeenCalled(); + const transactionsElement = screen.getByTestId('transactions'); + expect(transactionsElement.textContent).toBe(JSON.stringify(contracts)); }); }); - it('should handle generic error during fallback', async () => { - const writeContractAsyncMock = vi - .fn() - .mockRejectedValue(new Error('Generic error')); - (useWriteContract as ReturnType).mockReturnValue({ - status: 'idle', - writeContractAsync: writeContractAsyncMock, - }); + it('should set transactions based on calls', async () => { + const calls = [{ to: '0x456', data: '0xabcdef' }]; render( - + , ); - const button = screen.getByText('Submit'); - fireEvent.click(button); await waitFor(() => { - expect(screen.getByTestId('context-value-errorCode').textContent).toBe( - 'TmTPc02', - ); - expect(screen.getByTestId('context-value-errorMessage').textContent).toBe( - 'Something went wrong. Please try again.', - ); + const transactionsElement = screen.getByTestId('transactions'); + expect(transactionsElement.textContent).toBe(JSON.stringify(calls)); }); }); - it('should call setLifeCycleStatus when calling fallbackToWriteContract when executeContracts fails', async () => { - const writeContractsAsyncMock = vi - .fn() - .mockRejectedValue(new Error(METHOD_NOT_SUPPORTED_ERROR_SUBSTRING)); - const writeContractAsyncMock = vi - .fn() - .mockRejectedValue(new Error('Basic error')); - (useWriteContracts as ReturnType).mockReturnValue({ - status: 'idle', - writeContractsAsync: writeContractsAsyncMock, - }); - (useWriteContract as ReturnType).mockReturnValue({ - status: 'idle', - writeContractAsync: writeContractAsyncMock, - }); - render( - - - , - ); - const button = screen.getByText('Submit'); - fireEvent.click(button); - await waitFor(() => { - expect(screen.getByTestId('context-value-errorCode').textContent).toBe( - 'TmTPc02', - ); - expect(screen.getByTestId('context-value-errorMessage').textContent).toBe( - 'Something went wrong. Please try again.', + it('should throw an error when neither contracts nor calls are provided', async () => { + const restore = silenceError(); + expect(() => { + render( + +
Test
+
, ); - }); + }).toThrowError( + 'Transaction: One of contracts or calls must be provided as a prop to the Transaction component.', + ); + restore(); }); - it('should call setLifeCycleStatus when calling fallbackToWriteContract when user rejects request', async () => { - const writeContractsAsyncMock = vi - .fn() - .mockRejectedValue(new Error(METHOD_NOT_SUPPORTED_ERROR_SUBSTRING)); - const writeContractAsyncMock = vi - .fn() - .mockRejectedValue({ cause: { name: 'UserRejectedRequestError' } }); - (useWriteContracts as ReturnType).mockReturnValue({ - status: 'idle', - writeContractsAsync: writeContractsAsyncMock, - }); - (useWriteContract as ReturnType).mockReturnValue({ - status: 'idle', - writeContractAsync: writeContractAsyncMock, - }); - render( - - - , - ); - const button = screen.getByText('Submit'); - fireEvent.click(button); - await waitFor(() => { - expect(screen.getByTestId('context-value-errorCode').textContent).toBe( - 'TmTPc02', - ); - expect(screen.getByTestId('context-value-errorMessage').textContent).toBe( - 'Request denied.', + it('should throw an error when both contracts and calls are provided', async () => { + const restore = silenceError(); + expect(() => { + render( + +
Test
+
, ); - }); + }).toThrowError( + 'Transaction: Only one of contracts or calls can be provided as a prop to the Transaction component.', + ); + restore(); }); }); diff --git a/src/transaction/components/TransactionProvider.tsx b/src/transaction/components/TransactionProvider.tsx index f0e72c8ca2..a9568fcada 100644 --- a/src/transaction/components/TransactionProvider.tsx +++ b/src/transaction/components/TransactionProvider.tsx @@ -3,6 +3,7 @@ import { useCallback, useContext, useEffect, + useMemo, useState, } from 'react'; import type { Address } from 'viem'; @@ -15,8 +16,15 @@ import { import { waitForTransactionReceipt } from 'wagmi/actions'; import { useValue } from '../../internal/hooks/useValue'; import { useOnchainKit } from '../../useOnchainKit'; -import { GENERIC_ERROR_MESSAGE } from '../constants'; +import { + GENERIC_ERROR_MESSAGE, + TRANSACTION_TYPE_CALLS, + TRANSACTION_TYPE_CONTRACTS, +} from '../constants'; import { useCallsStatus } from '../hooks/useCallsStatus'; +import { useSendCall } from '../hooks/useSendCall'; +import { useSendCalls } from '../hooks/useSendCalls'; +import { useSendWalletTransactions } from '../hooks/useSendWalletTransactions'; import { useWriteContract } from '../hooks/useWriteContract'; import { useWriteContracts } from '../hooks/useWriteContracts'; import type { @@ -42,6 +50,7 @@ export function useTransactionContext() { } export function TransactionProvider({ + calls, capabilities, chainId, children, @@ -62,13 +71,30 @@ export function TransactionProvider({ }); // Component lifecycle const [transactionId, setTransactionId] = useState(''); const [transactionHashList, setTransactionHashList] = useState([]); + const transactions = calls || contracts; + const transactionType = calls + ? TRANSACTION_TYPE_CALLS + : TRANSACTION_TYPE_CONTRACTS; // Retrieve wallet capabilities const { walletCapabilities } = useOnchainKit(); const { switchChainAsync } = useSwitchChain(); - // Hooks that depend from Core Hooks + // Validate `calls` and `contracts` props + if (!contracts && !calls) { + throw new Error( + 'Transaction: One of contracts or calls must be provided as a prop to the Transaction component.', + ); + } + if (calls && contracts) { + throw new Error( + 'Transaction: Only one of contracts or calls can be provided as a prop to the Transaction component.', + ); + } + + // useWriteContracts or useWriteContract + // Used for contract calls with an ABI and functions. const { status: statusWriteContracts, writeContractsAsync } = useWriteContracts({ setLifeCycleStatus, @@ -82,12 +108,68 @@ export function TransactionProvider({ setLifeCycleStatus, transactionHashList, }); - const { transactionHash, status: callStatus } = useCallsStatus({ + // useSendCalls or useSendCall + // Used for contract calls with raw calldata. + const { status: statusSendCalls, sendCallsAsync } = useSendCalls({ setLifeCycleStatus, - transactionId, + setTransactionId, + }); + const { + status: statusSendCall, + sendCallAsync, + data: sendCallTransactionHash, + } = useSendCall({ + setLifeCycleStatus, + transactionHashList, + }); + + // Transaction Status + // For batched, use statusSendCalls or statusWriteContracts + // For single, use statusSendCall or statusWriteContract + const transactionStatus = useMemo(() => { + const transactionStatuses = walletCapabilities.hasAtomicBatch + ? { + [TRANSACTION_TYPE_CALLS]: statusSendCalls, + [TRANSACTION_TYPE_CONTRACTS]: statusWriteContracts, + } + : { + [TRANSACTION_TYPE_CALLS]: statusSendCall, + [TRANSACTION_TYPE_CONTRACTS]: statusWriteContract, + }; + return transactionStatuses[transactionType]; + }, [ + statusSendCalls, + statusWriteContracts, + statusSendCall, + statusWriteContract, + transactionType, + walletCapabilities.hasAtomicBatch, + ]); + + // Transaction hash for single transaction (non-batched) + const singleTransactionHash = + writeContractTransactionHash || sendCallTransactionHash; + + // useSendWalletTransactions + // Used to send transactions based on the transaction type. Can be of type calls or contracts. + const sendWalletTransactions = useSendWalletTransactions({ + capabilities, + sendCallAsync, + sendCallsAsync, + transactions, + transactionType, + walletCapabilities, + writeContractAsync, + writeContractsAsync, }); + + const { transactionHash: batchedTransactionHash, status: callStatus } = + useCallsStatus({ + setLifeCycleStatus, + transactionId, + }); const { data: receipt } = useWaitForTransactionReceipt({ - hash: writeContractTransactionHash || transactionHash, + hash: singleTransactionHash || batchedTransactionHash, }); // Component lifecycle emitters @@ -122,16 +204,13 @@ export function TransactionProvider({ // Set transaction pending status when writeContracts or writeContract is pending useEffect(() => { - if ( - statusWriteContracts === 'pending' || - statusWriteContract === 'pending' - ) { + if (transactionStatus === 'pending') { setLifeCycleStatus({ statusName: 'transactionPending', statusData: null, }); } - }, [statusWriteContracts, statusWriteContract]); + }, [transactionStatus]); // Trigger success status when receipt is generated by useWaitForTransactionReceipt useEffect(() => { @@ -149,13 +228,14 @@ export function TransactionProvider({ // When all transactions are succesful, get the receipts useEffect(() => { if ( - transactionHashList.length !== contracts.length || - contracts.length < 2 + !transactions || + transactionHashList.length !== transactions.length || + transactions.length < 2 ) { return; } getTransactionLegacyReceipts(); - }, [contracts, transactionHashList]); + }, [transactions, transactionHashList]); const getTransactionLegacyReceipts = useCallback(async () => { const receipts = []; @@ -185,28 +265,6 @@ export function TransactionProvider({ }); }, [chainId, config, transactionHashList]); - const fallbackToWriteContract = useCallback(async () => { - // EOAs don't support batching, so we process contracts individually. - // This gracefully handles accidental batching attempts with EOAs. - for (const contract of contracts) { - try { - await writeContractAsync?.(contract); - } catch (err) { - const errorMessage = isUserRejectedRequestError(err) - ? 'Request denied.' - : GENERIC_ERROR_MESSAGE; - setLifeCycleStatus({ - statusName: 'error', - statusData: { - code: 'TmTPc02', // Transaction module TransactionProvider component 02 error - error: JSON.stringify(err), - message: errorMessage, - }, - }); - } - } - }, [contracts, writeContractAsync]); - const switchChain = useCallback( async (targetChainId: number | undefined) => { if (targetChainId && account.chainId !== targetChainId) { @@ -222,16 +280,7 @@ export function TransactionProvider({ try { // Switch chain before attempting transactions await switchChain(chainId); - if (walletCapabilities.hasAtomicBatch) { - // Use experiemental hook if the wallet supports atomic batch - await writeContractsAsync({ - contracts, - capabilities, - }); - } else { - // Use fallback if the wallet does not support atomic batch - await fallbackToWriteContract(); - } + await sendWalletTransactions(); } catch (err) { const errorMessage = isUserRejectedRequestError(err) ? 'Request denied.' @@ -245,19 +294,10 @@ export function TransactionProvider({ }, }); } - }, [ - chainId, - capabilities, - contracts, - fallbackToWriteContract, - switchChain, - writeContractsAsync, - walletCapabilities.hasAtomicBatch, - ]); + }, [chainId, sendWalletTransactions, switchChain]); const value = useValue({ chainId, - contracts, errorCode, errorMessage, isLoading: callStatus === 'PENDING', @@ -269,8 +309,9 @@ export function TransactionProvider({ setIsToastVisible, setLifeCycleStatus, setTransactionId, + transactions, transactionId, - transactionHash: transactionHash || writeContractTransactionHash, + transactionHash: singleTransactionHash || batchedTransactionHash, }); return ( diff --git a/src/transaction/constants.ts b/src/transaction/constants.ts index dcbefc7272..7e3ef84104 100644 --- a/src/transaction/constants.ts +++ b/src/transaction/constants.ts @@ -3,3 +3,5 @@ export const GENERIC_ERROR_MESSAGE = 'Something went wrong. Please try again.'; export const METHOD_NOT_SUPPORTED_ERROR_SUBSTRING = 'this request method is not supported'; export const SEND_CALLS_NOT_SUPPORTED_ERROR = 'SEND_CALLS_NOT_SUPPORTED_ERROR'; +export const TRANSACTION_TYPE_CALLS = 'TRANSACTION_TYPE_CALLS'; +export const TRANSACTION_TYPE_CONTRACTS = 'TRANSACTION_TYPE_CONTRACTS'; diff --git a/src/transaction/hooks/useSendWalletTransactions.test.tsx b/src/transaction/hooks/useSendWalletTransactions.test.tsx new file mode 100644 index 0000000000..8954d64122 --- /dev/null +++ b/src/transaction/hooks/useSendWalletTransactions.test.tsx @@ -0,0 +1,88 @@ +import { renderHook } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import { + TRANSACTION_TYPE_CALLS, + TRANSACTION_TYPE_CONTRACTS, +} from '../constants'; +import { sendBatchedTransactions } from '../utils/sendBatchedTransactions'; +import { sendSingleTransactions } from '../utils/sendSingleTransactions'; +import { useSendWalletTransactions } from './useSendWalletTransactions'; + +// Mock the utility functions +vi.mock('../utils/sendBatchedTransactions'); +vi.mock('../utils/sendSingleTransactions'); + +describe('useSendWalletTransactions', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should handle batched transactions', async () => { + const transactions = [{ to: '0x123', data: '0x456' }]; + const capabilities = { someCapability: true }; + const { result } = renderHook(() => + useSendWalletTransactions({ + transactions, + transactionType: TRANSACTION_TYPE_CONTRACTS, + capabilities, + writeContractsAsync: vi.fn(), + writeContractAsync: vi.fn(), + sendCallsAsync: vi.fn(), + sendCallAsync: vi.fn(), + walletCapabilities: { hasAtomicBatch: true }, + }), + ); + await result.current(); + expect(sendBatchedTransactions).toHaveBeenCalledWith({ + capabilities, + sendCallsAsync: expect.any(Function), + transactions, + transactionType: TRANSACTION_TYPE_CONTRACTS, + writeContractsAsync: expect.any(Function), + }); + }); + + it('should handle non-batched transactions', async () => { + const transactions = [ + { to: '0x123', data: '0x456' }, + { to: '0x789', data: '0xabc' }, + ]; + const { result } = renderHook(() => + useSendWalletTransactions({ + transactions, + transactionType: TRANSACTION_TYPE_CALLS, + capabilities: undefined, + writeContractsAsync: vi.fn(), + writeContractAsync: vi.fn(), + sendCallsAsync: vi.fn(), + sendCallAsync: vi.fn(), + walletCapabilities: { hasAtomicBatch: false }, + }), + ); + await result.current(); + expect(sendSingleTransactions).toHaveBeenCalledWith({ + sendCallAsync: expect.any(Function), + transactions, + transactionType: TRANSACTION_TYPE_CALLS, + writeContractAsync: expect.any(Function), + }); + }); + + it('should handle no transactions', async () => { + const { result } = renderHook(() => + useSendWalletTransactions({ + transactions: undefined, + transactionType: TRANSACTION_TYPE_CONTRACTS, + capabilities: undefined, + writeContractsAsync: vi.fn(), + writeContractAsync: vi.fn(), + sendCallsAsync: vi.fn(), + sendCallAsync: vi.fn(), + walletCapabilities: { hasAtomicBatch: false }, + }), + ); + await result.current(); + expect(sendBatchedTransactions).not.toHaveBeenCalled(); + expect(sendSingleTransactions).not.toHaveBeenCalled(); + }); +}); diff --git a/src/transaction/hooks/useSendWalletTransactions.tsx b/src/transaction/hooks/useSendWalletTransactions.tsx new file mode 100644 index 0000000000..f2c395363e --- /dev/null +++ b/src/transaction/hooks/useSendWalletTransactions.tsx @@ -0,0 +1,49 @@ +import { useCallback } from 'react'; +import type { UseSendWalletTransactionsParams } from '../types'; +import { sendBatchedTransactions } from '../utils/sendBatchedTransactions'; +import { sendSingleTransactions } from '../utils/sendSingleTransactions'; + +// Sends transactions to the wallet using the appropriate hook based on Transaction props and wallet capabilities +export const useSendWalletTransactions = ({ + capabilities, + sendCallAsync, + sendCallsAsync, + transactions, + transactionType, + walletCapabilities, + writeContractAsync, + writeContractsAsync, +}: UseSendWalletTransactionsParams) => { + return useCallback(async () => { + if (!transactions) { + return; + } + if (walletCapabilities.hasAtomicBatch) { + // Batched transactions + await sendBatchedTransactions({ + capabilities, + sendCallsAsync, + transactions, + transactionType, + writeContractsAsync, + }); + } else { + // Non-batched transactions + await sendSingleTransactions({ + sendCallAsync, + transactions, + transactionType, + writeContractAsync, + }); + } + }, [ + writeContractsAsync, + writeContractAsync, + sendCallsAsync, + sendCallAsync, + capabilities, + transactions, + transactionType, + walletCapabilities.hasAtomicBatch, + ]); +}; diff --git a/src/transaction/types.ts b/src/transaction/types.ts index 3d73b08e3b..78808ef517 100644 --- a/src/transaction/types.ts +++ b/src/transaction/types.ts @@ -1,10 +1,23 @@ -// 🌲☀🌲 import type { ReactNode } from 'react'; import type { Address, ContractFunctionParameters, + Hex, TransactionReceipt, } from 'viem'; +import type { Config } from 'wagmi'; +import type { + SendTransactionMutateAsync, + WriteContractMutateAsync, +} from 'wagmi/query'; +import type { WalletCapabilities as OnchainKitWalletCapabilities } from '../types'; +// 🌲☀🌲 +import { + TRANSACTION_TYPE_CALLS, + TRANSACTION_TYPE_CONTRACTS, +} from './constants'; + +export type Call = { to: Hex; data?: Hex; value?: bigint }; /** * List of transaction lifecycle statuses. @@ -62,7 +75,6 @@ export type TransactionButtonReact = { export type TransactionContextType = { chainId?: number; // The chainId for the transaction. - contracts: ContractFunctionParameters[]; // An array of contracts for the transaction. errorCode?: string; // An error code used to localize errors and provide more context with unit-tests. errorMessage?: string; // An error message string if the transaction encounters an issue. isLoading: boolean; // A boolean indicating if the transaction is currently loading. @@ -74,6 +86,7 @@ export type TransactionContextType = { setIsToastVisible: (isVisible: boolean) => void; // A function to set the visibility of the transaction toast. setLifeCycleStatus: (state: LifeCycleStatus) => void; // A function to set the lifecycle status of the component setTransactionId: (id: string) => void; // A function to set the transaction ID. + transactions?: Call[] | ContractFunctionParameters[]; // An array of transactions for the component. transactionId?: string; // An optional string representing the ID of the transaction. transactionHash?: string; // An optional string representing the hash of the transaction. }; @@ -85,6 +98,23 @@ type PaymasterService = { url: string; }; +export type sendBatchedTransactionsParams = { + capabilities?: WalletCapabilities; + // biome-ignore lint: cannot find module 'wagmi/experimental/query' + sendCallsAsync: any; + transactions?: Call[] | ContractFunctionParameters[]; + transactionType: string; + // biome-ignore lint: cannot find module 'wagmi/experimental/query' + writeContractsAsync: any; +}; + +export type sendSingleTransactionParams = { + sendCallAsync: SendTransactionMutateAsync | (() => void); + transactions: Call[] | ContractFunctionParameters[]; + transactionType: string; + writeContractAsync: WriteContractMutateAsync | (() => void); +}; + /** * Note: exported as public Type */ @@ -95,10 +125,11 @@ export type TransactionError = { }; export type TransactionProviderReact = { + calls?: Call[]; // An array of calls for the transaction. Mutually exclusive with the `contracts` prop. capabilities?: WalletCapabilities; // Capabilities that a wallet supports (e.g. paymasters, session keys, etc). chainId?: number; // The chainId for the transaction. children: ReactNode; // The child components to be rendered within the provider component. - contracts: ContractFunctionParameters[]; // An array of contract function parameters provided to the child components. + contracts?: ContractFunctionParameters[]; // An array of contract function parameters provided to the child components. Mutually exclusive with the `calls` prop. onError?: (e: TransactionError) => void; // An optional callback function that handles errors within the provider. onStatus?: (lifeCycleStatus: LifeCycleStatus) => void; // An optional callback function that exposes the component lifecycle state onSuccess?: (response: TransactionResponse) => void; // An optional callback function that exposes the transaction receipts @@ -108,11 +139,12 @@ export type TransactionProviderReact = { * Note: exported as public Type */ export type TransactionReact = { + calls?: Call[]; // An array of calls to be made in the transaction. Mutually exclusive with the `contracts` prop. capabilities?: WalletCapabilities; // Capabilities that a wallet supports (e.g. paymasters, session keys, etc). chainId?: number; // The chainId for the transaction. children: ReactNode; // The child components to be rendered within the transaction component. className?: string; // An optional CSS class name for styling the component. - contracts: ContractFunctionParameters[]; // An array of contract function parameters for the transaction. + contracts?: ContractFunctionParameters[]; // An array of contract function parameters for the transaction. Mutually exclusive with the `calls` prop. onError?: (e: TransactionError) => void; // An optional callback function that handles transaction errors. onStatus?: (lifeCycleStatus: LifeCycleStatus) => void; // An optional callback function that exposes the component lifecycle state onSuccess?: (response: TransactionResponse) => void; // An optional callback function that exposes the transaction receipts @@ -210,6 +242,35 @@ export type UseSendCallsParams = { setTransactionId: (id: string) => void; }; +export type UseSendWalletTransactionsParams = { + capabilities?: WalletCapabilities; + // biome-ignore lint: cannot find module 'wagmi/experimental/query' + sendCallsAsync: any; + sendCallAsync: SendTransactionMutateAsync | (() => void); + transactions?: Call[] | ContractFunctionParameters[]; + transactionType: string; + walletCapabilities: OnchainKitWalletCapabilities; + // biome-ignore lint: cannot find module 'wagmi/experimental/query' + writeContractsAsync: any; + writeContractAsync: WriteContractMutateAsync | (() => void); +}; + +export type UseTransactionTypeParams = { + calls?: Call[]; + contracts?: ContractFunctionParameters[]; + transactionStatuses: { + [TRANSACTION_TYPE_CALLS]: { + single: string; + batch: string; + }; + [TRANSACTION_TYPE_CONTRACTS]: { + single: string; + batch: string; + }; + }; + walletCapabilities: OnchainKitWalletCapabilities; +}; + /** * Note: exported as public Type * diff --git a/src/transaction/utils/sendBatchedTransactions.test.ts b/src/transaction/utils/sendBatchedTransactions.test.ts new file mode 100644 index 0000000000..1761f1b21f --- /dev/null +++ b/src/transaction/utils/sendBatchedTransactions.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + TRANSACTION_TYPE_CALLS, + TRANSACTION_TYPE_CONTRACTS, +} from '../constants'; +import { sendBatchedTransactions } from './sendBatchedTransactions'; + +describe('sendBatchedTransactions', () => { + const mockWriteContractsAsync = vi.fn(); + const mockSendCallsAsync = vi.fn(); + const mockTransactions = []; + const mockCapabilities = { paymasterService: '' }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call writeContractsAsync for contract transactions', async () => { + await sendBatchedTransactions({ + capabilities: mockCapabilities, + sendCallsAsync: mockSendCallsAsync, + transactions: mockTransactions, + transactionType: TRANSACTION_TYPE_CONTRACTS, + writeContractsAsync: mockWriteContractsAsync, + }); + expect(mockWriteContractsAsync).toHaveBeenCalledWith({ + contracts: mockTransactions, + capabilities: mockCapabilities, + }); + expect(mockSendCallsAsync).not.toHaveBeenCalled(); + }); + + it('should call sendCallsAsync for call transactions', async () => { + await sendBatchedTransactions({ + capabilities: mockCapabilities, + sendCallsAsync: mockSendCallsAsync, + transactions: mockTransactions, + transactionType: TRANSACTION_TYPE_CALLS, + writeContractsAsync: mockWriteContractsAsync, + }); + expect(mockSendCallsAsync).toHaveBeenCalledWith({ + calls: mockTransactions, + capabilities: mockCapabilities, + }); + expect(mockWriteContractsAsync).not.toHaveBeenCalled(); + }); + + it('should not call any function if transactions are undefined', async () => { + await sendBatchedTransactions({ + capabilities: mockCapabilities, + sendCallsAsync: mockSendCallsAsync, + transactions: undefined, + transactionType: TRANSACTION_TYPE_CONTRACTS, + writeContractsAsync: mockWriteContractsAsync, + }); + expect(mockWriteContractsAsync).not.toHaveBeenCalled(); + expect(mockSendCallsAsync).not.toHaveBeenCalled(); + }); + + it('should not call any function if transaction type is invalid', async () => { + await sendBatchedTransactions({ + capabilities: mockCapabilities, + sendCallsAsync: mockSendCallsAsync, + transactions: mockTransactions, + transactionType: 'INVALID_TYPE', + writeContractsAsync: mockWriteContractsAsync, + }); + expect(mockWriteContractsAsync).not.toHaveBeenCalled(); + expect(mockSendCallsAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/src/transaction/utils/sendBatchedTransactions.ts b/src/transaction/utils/sendBatchedTransactions.ts new file mode 100644 index 0000000000..f30a583573 --- /dev/null +++ b/src/transaction/utils/sendBatchedTransactions.ts @@ -0,0 +1,29 @@ +import { + TRANSACTION_TYPE_CALLS, + TRANSACTION_TYPE_CONTRACTS, +} from '../constants'; +import type { sendBatchedTransactionsParams } from '../types'; + +export const sendBatchedTransactions = async ({ + capabilities, + sendCallsAsync, + transactions, + transactionType, + writeContractsAsync, +}: sendBatchedTransactionsParams) => { + if (!transactions) { + return; + } + if (transactionType === TRANSACTION_TYPE_CONTRACTS) { + await writeContractsAsync({ + contracts: transactions, + capabilities, + }); + } + if (transactionType === TRANSACTION_TYPE_CALLS) { + await sendCallsAsync({ + calls: transactions, + capabilities, + }); + } +}; diff --git a/src/transaction/utils/sendSingleTransactions.test.ts b/src/transaction/utils/sendSingleTransactions.test.ts new file mode 100644 index 0000000000..d8958960d8 --- /dev/null +++ b/src/transaction/utils/sendSingleTransactions.test.ts @@ -0,0 +1,71 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { TRANSACTION_TYPE_CALLS } from '../constants'; +import type { Call } from '../types'; +import { sendSingleTransactions } from './sendSingleTransactions'; + +describe('sendSingleTransactions', () => { + const mockSendCallAsync = vi.fn(); + const mockWriteContractAsync = vi.fn(); + const transactions: Call[] = [ + { to: '0x123', data: '0x456' }, + { to: '0x789', data: '0xabc' }, + ]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should call sendCallAsync for each transaction when type is TRANSACTION_TYPE_CALLS', async () => { + await sendSingleTransactions({ + sendCallAsync: mockSendCallAsync, + transactions, + transactionType: TRANSACTION_TYPE_CALLS, + writeContractAsync: mockWriteContractAsync, + }); + expect(mockSendCallAsync).toHaveBeenCalledTimes(2); + expect(mockSendCallAsync).toHaveBeenNthCalledWith(1, transactions[0]); + expect(mockSendCallAsync).toHaveBeenNthCalledWith(2, transactions[1]); + expect(mockWriteContractAsync).not.toHaveBeenCalled(); + }); + + it('should call writeContractAsync for each transaction when type is not TRANSACTION_TYPE_CALLS', async () => { + await sendSingleTransactions({ + sendCallAsync: mockSendCallAsync, + transactions, + transactionType: 'SOME_OTHER_TYPE', + writeContractAsync: mockWriteContractAsync, + }); + expect(mockWriteContractAsync).toHaveBeenCalledTimes(2); + expect(mockWriteContractAsync).toHaveBeenNthCalledWith(1, transactions[0]); + expect(mockWriteContractAsync).toHaveBeenNthCalledWith(2, transactions[1]); + expect(mockSendCallAsync).not.toHaveBeenCalled(); + }); + + it('should not call any function if transactions array is empty', async () => { + await sendSingleTransactions({ + sendCallAsync: mockSendCallAsync, + transactions: [], + transactionType: TRANSACTION_TYPE_CALLS, + writeContractAsync: mockWriteContractAsync, + }); + expect(mockSendCallAsync).not.toHaveBeenCalled(); + expect(mockWriteContractAsync).not.toHaveBeenCalled(); + }); + + it('should handle mixed transaction types correctly', async () => { + await sendSingleTransactions({ + sendCallAsync: mockSendCallAsync, + transactions, + transactionType: TRANSACTION_TYPE_CALLS, + writeContractAsync: mockWriteContractAsync, + }); + expect(mockSendCallAsync).toHaveBeenCalledTimes(2); + await sendSingleTransactions({ + sendCallAsync: mockSendCallAsync, + transactions, + transactionType: 'CONTRACT_TYPE', + writeContractAsync: mockWriteContractAsync, + }); + expect(mockWriteContractAsync).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/transaction/utils/sendSingleTransactions.ts b/src/transaction/utils/sendSingleTransactions.ts new file mode 100644 index 0000000000..f96f23c820 --- /dev/null +++ b/src/transaction/utils/sendSingleTransactions.ts @@ -0,0 +1,18 @@ +import type { ContractFunctionParameters } from 'viem'; +import { TRANSACTION_TYPE_CALLS } from '../constants'; +import type { Call, sendSingleTransactionParams } from '../types'; + +export const sendSingleTransactions = async ({ + sendCallAsync, + transactions, + transactionType, + writeContractAsync, +}: sendSingleTransactionParams) => { + for (const transaction of transactions) { + if (transactionType === TRANSACTION_TYPE_CALLS) { + await sendCallAsync(transaction as Call); + } else { + await writeContractAsync(transaction as ContractFunctionParameters); + } + } +};