Skip to content

Commit

Permalink
feat: add atomicBatch Swap transactions with ERC-20 from token (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
0xAlec committed Sep 20, 2024
1 parent a344c31 commit 9f08612
Show file tree
Hide file tree
Showing 13 changed files with 683 additions and 292 deletions.
5 changes: 5 additions & 0 deletions .changeset/itchy-socks-drop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@coinbase/onchainkit': patch
---

**feat**: added batched Swap transactions from ERC-20. by @0xAlec #1272
73 changes: 72 additions & 1 deletion src/swap/components/SwapProvider.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@ import {
} from '@testing-library/react';
import React, { act, useCallback, useEffect } from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { http, WagmiProvider, createConfig, useAccount } from 'wagmi';
import {
http,
WagmiProvider,
createConfig,
useAccount,
useChainId,
useSwitchChain,
} from 'wagmi';
import { waitForTransactionReceipt } from 'wagmi/actions';
import { base } from 'wagmi/chains';
import { mock } from 'wagmi/connectors';
import { useSendCalls } from 'wagmi/experimental';
import { buildSwapTransaction } from '../../api/buildSwapTransaction';
import { getSwapQuote } from '../../api/getSwapQuote';
import { useCapabilitiesSafe } from '../../internal/hooks/useCapabilitiesSafe';
import { DEGEN_TOKEN, ETH_TOKEN } from '../mocks';
import { getSwapErrorCode } from '../utils/getSwapErrorCode';
import { SwapProvider, useSwapContext } from './SwapProvider';
Expand All @@ -36,13 +46,33 @@ vi.mock('../utils/processSwapTransaction', () => ({
processSwapTransaction: vi.fn(),
}));

const mockSwitchChain = vi.fn();
vi.mock('wagmi', async (importOriginal) => {
return {
...(await importOriginal<typeof import('wagmi')>()),
useAccount: vi.fn(),
useChainId: vi.fn(),
useSwitchChain: vi.fn(),
};
});

const mockAwaitCalls = vi.fn();
vi.mock('../hooks/useAwaitCalls', () => ({
useAwaitCalls: () => useCallback(mockAwaitCalls, []),
}));

vi.mock('../../internal/hooks/useCapabilitiesSafe', () => ({
useCapabilitiesSafe: vi.fn(),
}));

vi.mock('wagmi/actions', () => ({
waitForTransactionReceipt: vi.fn(),
}));

vi.mock('wagmi/experimental', () => ({
useSendCalls: vi.fn(),
}));

vi.mock('../path/to/maxSlippageModule', () => ({
getMaxSlippage: vi.fn().mockReturnValue(10),
}));
Expand Down Expand Up @@ -194,6 +224,14 @@ describe('useSwapContext', () => {
(useAccount as ReturnType<typeof vi.fn>).mockReturnValue({
address: '0x123',
});
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(8453);
(useSendCalls as ReturnType<typeof vi.fn>).mockReturnValue({
status: 'idle',
sendCallsAsync: vi.fn(),
});
(useSwitchChain as ReturnType<typeof vi.fn>).mockReturnValue({
switchChainAsync: mockSwitchChain,
});
await act(async () => {
renderWithProviders({ Component: () => null });
});
Expand Down Expand Up @@ -239,6 +277,15 @@ describe('SwapProvider', () => {
(useAccount as ReturnType<typeof vi.fn>).mockReturnValue({
address: '0x123',
});
(useChainId as ReturnType<typeof vi.fn>).mockReturnValue(8453);
(useSendCalls as ReturnType<typeof vi.fn>).mockReturnValue({
status: 'idle',
sendCallsAsync: vi.fn(),
});
(useSwitchChain as ReturnType<typeof vi.fn>).mockReturnValue({
switchChainAsync: mockSwitchChain,
});
(useCapabilitiesSafe as ReturnType<typeof vi.fn>).mockReturnValue({});
});

it('should reset inputs when setLifecycleStatus is called with success', async () => {
Expand All @@ -257,6 +304,30 @@ describe('SwapProvider', () => {
expect(mockResetFunction).toHaveBeenCalledTimes(1);
});

it('should handle batched transactions', async () => {
const { result } = renderHook(() => useSwapContext(), { wrapper });
(useCapabilitiesSafe as ReturnType<typeof vi.fn>).mockReturnValue({
atomicBatch: { supported: true },
paymasterService: { supported: true },
auxiliaryFunds: { supported: true },
});
(waitForTransactionReceipt as ReturnType<typeof vi.fn>).mockResolvedValue({
transactionHash: 'receiptHash',
});
await act(async () => {
result.current.updateLifecycleStatus({
statusName: 'transactionApproved',
statusData: {
transactionType: 'Batched',
},
});
});
await waitFor(() => {
expect(mockAwaitCalls).toHaveBeenCalled();
});
expect(mockAwaitCalls).toHaveBeenCalledTimes(1);
});

it('should emit onError when setLifecycleStatus is called with error', async () => {
const onErrorMock = vi.fn();
renderWithProviders({ Component: TestSwapComponent, onError: onErrorMock });
Expand Down
48 changes: 44 additions & 4 deletions src/swap/components/SwapProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,20 @@ import {
useEffect,
useState,
} from 'react';
import { base } from 'viem/chains';
import { useAccount, useConfig, useSendTransaction } from 'wagmi';
import { useSwitchChain } from 'wagmi';
import { useSendCalls } from 'wagmi/experimental';
import { buildSwapTransaction } from '../../api/buildSwapTransaction';
import { getSwapQuote } from '../../api/getSwapQuote';
import { useCapabilitiesSafe } from '../../internal/hooks/useCapabilitiesSafe';
import { useValue } from '../../internal/hooks/useValue';
import { formatTokenAmount } from '../../internal/utils/formatTokenAmount';
import type { Token } from '../../token';
import { GENERIC_ERROR_MESSAGE } from '../../transaction/constants';
import { isUserRejectedRequestError } from '../../transaction/utils/isUserRejectedRequestError';
import { DEFAULT_MAX_SLIPPAGE } from '../constants';
import { useAwaitCalls } from '../hooks/useAwaitCalls';
import { useFromTo } from '../hooks/useFromTo';
import { useResetInputs } from '../hooks/useResetInputs';
import type {
Expand Down Expand Up @@ -47,11 +52,16 @@ export function SwapProvider({
onStatus,
onSuccess,
}: SwapProviderReact) {
const { address } = useAccount();
const { address, chainId } = useAccount();
const { switchChainAsync } = useSwitchChain();
// Feature flags
const { useAggregator } = experimental;
// Core Hooks
const accountConfig = useConfig();

const walletCapabilities = useCapabilitiesSafe({
chainId: base.id,
}); // Swap is only available on Base
const [lifecycleStatus, setLifecycleStatus] = useState<LifecycleStatus>({
statusName: 'init',
statusData: {
Expand Down Expand Up @@ -86,9 +96,16 @@ export function SwapProvider({
const [hasHandledSuccess, setHasHandledSuccess] = useState(false);
const { from, to } = useFromTo(address);
const { sendTransactionAsync } = useSendTransaction(); // Sending the transaction (and approval, if applicable)
const { sendCallsAsync } = useSendCalls(); // Atomic Batch transactions (and approval, if applicable)

// Refreshes balances and inputs post-swap
const resetInputs = useResetInputs({ from, to });
// For batched transactions, listens to and awaits calls from the Wallet server
const awaitCallsStatus = useAwaitCalls({
accountConfig,
lifecycleStatus,
updateLifecycleStatus,
});

// Component lifecycle emitters
useEffect(() => {
Expand Down Expand Up @@ -122,6 +139,23 @@ export function SwapProvider({
}
}, [hasHandledSuccess, lifecycleStatus.statusName, resetInputs]);

useEffect(() => {
// For batched transactions, `transactionApproved` will contain the calls ID
// We'll use the `useAwaitCalls` hook to listen to the call status from the wallet server
// This will update the lifecycle status to `success` once the calls are confirmed
if (
lifecycleStatus.statusName === 'transactionApproved' &&
lifecycleStatus.statusData.transactionType === 'Batched'
) {
awaitCallsStatus();
}
}, [
awaitCallsStatus,
lifecycleStatus,
lifecycleStatus.statusData,
lifecycleStatus.statusName,
]);

useEffect(() => {
// Reset status to init after success has been handled
if (lifecycleStatus.statusName === 'success' && hasHandledSuccess) {
Expand Down Expand Up @@ -295,14 +329,16 @@ export function SwapProvider({
return;
}
await processSwapTransaction({
chainId,
config: accountConfig,
sendCallsAsync,
sendTransactionAsync,
updateLifecycleStatus,
swapTransaction: response,
switchChainAsync,
updateLifecycleStatus,
useAggregator,
walletCapabilities,
});

// TODO: refresh balances
} catch (err) {
const errorMessage = isUserRejectedRequestError(err)
? 'Request denied.'
Expand All @@ -319,13 +355,17 @@ export function SwapProvider({
}, [
accountConfig,
address,
chainId,
from.amount,
from.token,
lifecycleStatus,
sendCallsAsync,
sendTransactionAsync,
switchChainAsync,
to.token,
updateLifecycleStatus,
useAggregator,
walletCapabilities,
]);

const value = useValue({
Expand Down
Loading

0 comments on commit 9f08612

Please sign in to comment.