Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: more send utilities #1892

Merged
merged 8 commits into from
Jan 28, 2025
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions src/api/buildSendTransaction.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { type Address, encodeFunctionData, erc20Abi } from 'viem';
import { type Mock, describe, expect, it, vi } from 'vitest';
import { buildSendTransaction } from './buildSendTransaction';

vi.mock('viem', async () => {
const actual = await vi.importActual('viem');
return {
...actual,
encodeFunctionData: vi.fn(),
};
});

describe('buildSendTransaction', () => {
const mockRecipient = '0x742d35Cc6634C0532925a3b844Bc454e4438f44e';
const mockToken = '0x1234567890123456789012345678901234567890';
const mockAmount = 1000000000000000000n; // 1 ETH/token in wei

it('should build native ETH transfer transaction', () => {
const result = buildSendTransaction({
recipientAddress: mockRecipient,
tokenAddress: undefined as unknown as Address, // type assertion okay because we're testing the case where tokenAddress is undefined
amount: mockAmount,
});

expect(result).toEqual({
to: mockRecipient,
data: '0x',
value: mockAmount,
});
});

it('should build ERC20 token transfer transaction', () => {
const expectedCallData = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [mockRecipient, mockAmount],
});

const result = buildSendTransaction({
recipientAddress: mockRecipient,
tokenAddress: mockToken,
amount: mockAmount,
});

expect(result).toEqual({
to: mockToken,
data: expectedCallData,
});
});

it('should handle Error objects', () => {
(encodeFunctionData as Mock).mockImplementation(() => {
throw new Error('Test error');
});
const result = buildSendTransaction({
recipientAddress: mockRecipient,
tokenAddress: mockToken,
amount: mockAmount,
});

expect(result).toMatchObject({
code: 'AmBSeTa01',
message: 'Could not build transfer transaction',
error: 'Test error',
});
});

it('should handle non-Error objects', () => {
(encodeFunctionData as Mock).mockImplementation(() => {
throw 'Some string error';
});
const result = buildSendTransaction({
recipientAddress: mockRecipient,
tokenAddress: mockToken,
amount: mockAmount,
});

expect(result).toMatchObject({
code: 'AmBSeTa01',
message: 'Could not build transfer transaction',
error: 'Some string error',
});
});
});
40 changes: 40 additions & 0 deletions src/api/buildSendTransaction.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { encodeFunctionData, erc20Abi } from 'viem';
import type {
BuildSendTransactionParams,
BuildSendTransactionResponse,
} from './types';

export function buildSendTransaction({
recipientAddress,
tokenAddress,
amount,
}: BuildSendTransactionParams): BuildSendTransactionResponse {
// if no token address, we are sending native ETH
// and the data prop is empty
if (!tokenAddress) {
return {
to: recipientAddress,
data: '0x',
value: amount,
};
}

try {
const transferCallData = encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [recipientAddress, amount],
});
return {
to: tokenAddress,
data: transferCallData,
};
} catch (error) {
const message = error instanceof Error ? error.message : `${error}`;
return {
code: 'AmBSeTa01', // Api Module Build Send Transaction Error 01
error: message,
message: 'Could not build transfer transaction',
};
}
}
18 changes: 18 additions & 0 deletions src/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Address } from 'viem';
import type { Fee, QuoteWarning, SwapQuote, Transaction } from '../swap/types';
import type { Token } from '../token/types';
import type { Call } from '../transaction/types';

export type AddressOrETH = Address | 'ETH';

@@ -366,3 +367,20 @@ export type GetPortfoliosResponse = {
/** The portfolios for the provided addresses */
portfolios: Portfolio[];
};

/**
* Note: exported as public Type
*/
export type BuildSendTransactionParams = {
/** The address of the recipient */
recipientAddress: Address;
/** The address of the token to transfer */
tokenAddress: Address | null;
/** The amount of the transfer */
amount: bigint;
};

/**
* Note: exported as public Type
*/
export type BuildSendTransactionResponse = Call | APIError;
148 changes: 148 additions & 0 deletions src/internal/hooks/useSendTransaction.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { buildSendTransaction } from '@/api/buildSendTransaction';
import type { Token } from '@/token';
import { renderHook } from '@testing-library/react';
import { type Address, parseUnits } from 'viem';
import { type Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { useSendTransaction } from './useSendTransaction';

vi.mock('@/api/buildSendTransaction', () => ({
buildSendTransaction: vi.fn(),
}));

describe('useSendTransaction', () => {
const mockToken: Token = {
symbol: 'TOKEN',
decimals: 18,
address: '0x0987654321098765432109876543210987654321',
chainId: 8453,
image: '',
name: '',
};
const mockRecipientAddress = '0x1234567890123456789012345678901234567890';
const mockCallData = {
to: mockRecipientAddress,
data: mockToken.address,
value: parseUnits('1.0', 18),
};

beforeEach(() => {
vi.resetAllMocks();
});

it('returns empty calls when token is null', () => {
const { result } = renderHook(() =>
useSendTransaction({
recipientAddress: mockRecipientAddress,
token: null,
amount: '1.0',
}),
);

expect(result.current).toEqual({
code: 'AmBSeTx01', // Api Module Build Send Transaction Error 01
error: 'No token provided',
message: 'Could not build send transaction',
});
});

it('handles ETH transfers correctly', () => {
(buildSendTransaction as Mock).mockReturnValue({
...mockCallData,
data: '',
});
const { result } = renderHook(() =>
useSendTransaction({
recipientAddress: mockRecipientAddress,
token: { ...mockToken, address: '', symbol: 'ETH' },
amount: '1.0',
}),
);

expect(buildSendTransaction).toHaveBeenCalledWith({
recipientAddress: mockRecipientAddress,
tokenAddress: null,
amount: parseUnits('1.0', 18),
});
expect(result.current).toEqual({
to: mockRecipientAddress,
data: '',
value: parseUnits('1.0', 18),
});
});

it('returns error for non-ETH token without address', () => {
const { result } = renderHook(() =>
useSendTransaction({
recipientAddress: mockRecipientAddress,
token: {
...mockToken,
symbol: 'INVALID',
address: undefined as unknown as Address, // type assertion okay because we're testing the case where address is undefined
},
amount: '1.0',
}),
);

expect(result.current).toEqual({
code: 'AmBSeTx02', // Api Module Build Send Transaction Error 02
error: 'No token address provided for non-ETH token',
message: 'Could not build send transaction',
});
});

it('handles ERC20 token transfers correctly', () => {
const mockDecimals = 6;
const expectedCallData = {
to: mockRecipientAddress,
data: mockToken.address,
value: parseUnits('100', mockDecimals),
};
(buildSendTransaction as Mock).mockReturnValue(expectedCallData);

renderHook(() =>
useSendTransaction({
recipientAddress: mockRecipientAddress,
token: {
...mockToken,
symbol: 'USDC',
address: mockToken.address,
decimals: 6,
},
amount: '100',
}),
);
expect(buildSendTransaction).toHaveBeenCalledWith({
recipientAddress: mockRecipientAddress,
tokenAddress: mockToken.address,
amount: parseUnits('100', 6),
});
});

it('handles different decimal places correctly', () => {
const mockDecimals = 12;
const expectedCallData = {
to: mockRecipientAddress,
data: mockToken.address,
value: parseUnits('0.5', mockDecimals),
};
(buildSendTransaction as Mock).mockReturnValue(expectedCallData);

renderHook(() =>
useSendTransaction({
recipientAddress: mockRecipientAddress,
token: {
...mockToken,
symbol: 'TEST',
address: mockToken.address,
decimals: mockDecimals,
},
amount: '0.5',
}),
);
expect(buildSendTransaction).toHaveBeenCalledWith({
recipientAddress: mockRecipientAddress,
tokenAddress: mockToken.address,
amount: parseUnits('0.5', mockDecimals),
});
});
});
50 changes: 50 additions & 0 deletions src/internal/hooks/useSendTransaction.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { buildSendTransaction } from '@/api/buildSendTransaction';
import type { BuildSendTransactionResponse } from '@/api/types';
import type { Token } from '@/token';
import { type Address, parseUnits } from 'viem';

type UseSendTransactionParams = {
recipientAddress: Address;
token: Token | null;
amount: string;
};

export function useSendTransaction({
recipientAddress,
token,
amount,
}: UseSendTransactionParams): BuildSendTransactionResponse {
if (!token) {
return {
code: 'AmBSeTx01', // Api Module Build Send Transaction Error 01
error: 'No token provided',
message: 'Could not build send transaction',
};
}

if (!token.address) {
if (token.symbol !== 'ETH') {
return {
code: 'AmBSeTx02', // Api Module Build Send Transaction Error 02
error: 'No token address provided for non-ETH token',
message: 'Could not build send transaction',
};
}
const parsedAmount = parseUnits(amount, token.decimals);
const sendTransaction = buildSendTransaction({
recipientAddress,
tokenAddress: null,
amount: parsedAmount,
});
return sendTransaction;
}

const parsedAmount = parseUnits(amount, token.decimals);
const sendTransaction = buildSendTransaction({
recipientAddress,
tokenAddress: token.address,
amount: parsedAmount,
});

return sendTransaction;
}
94 changes: 94 additions & 0 deletions src/wallet/utils/validateAddressInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { getAddress } from '@/identity/utils/getAddress';
import { isBasename } from '@/identity/utils/isBasename';
import { isEns } from '@/identity/utils/isEns';
import { isAddress } from 'viem';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { validateAddressInput } from './validateAddressInput';

vi.mock('@/identity/utils/isBasename', () => ({
isBasename: vi.fn(),
}));

vi.mock('@/identity/utils/isEns', () => ({
isEns: vi.fn(),
}));

vi.mock('@/identity/utils/getAddress', () => ({
getAddress: vi.fn(),
}));

vi.mock('viem', async () => ({
isAddress: vi.fn(),
}));

describe('validateAddressInput', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('validates basename and returns resolved address on base', async () => {
const mockAddress = '0x1234567890123456789012345678901234567890';
vi.mocked(isBasename).mockReturnValue(true);
vi.mocked(isEns).mockReturnValue(false);
vi.mocked(getAddress).mockResolvedValue(mockAddress);

const result = await validateAddressInput('test.base');

expect(getAddress).toHaveBeenCalledWith({
name: 'test.base',
chain: expect.objectContaining({ id: 8453 }),
});
expect(result).toBe(mockAddress);
});

it('validates ENS and returns resolved address on mainnet', async () => {
const mockAddress = '0x1234567890123456789012345678901234567890';
vi.mocked(isBasename).mockReturnValue(false);
vi.mocked(isEns).mockReturnValue(true);
vi.mocked(getAddress).mockResolvedValue(mockAddress);

const result = await validateAddressInput('test.eth');

expect(getAddress).toHaveBeenCalledWith({
name: 'test.eth',
chain: expect.objectContaining({ id: 1 }),
});
expect(result).toBe(mockAddress);
});

it('returns null when name resolution fails', async () => {
vi.mocked(isBasename).mockReturnValue(true);
vi.mocked(isEns).mockReturnValue(false);
vi.mocked(getAddress).mockResolvedValue(null);
const basenameResult = await validateAddressInput('invalid.base');
expect(basenameResult).toBeNull();

vi.mocked(isBasename).mockReturnValue(false);
vi.mocked(isEns).mockReturnValue(true);
vi.mocked(getAddress).mockResolvedValue(null);
const ensResult = await validateAddressInput('invalid.eth');
expect(ensResult).toBeNull();
});

it('validates and returns raw ethereum address', async () => {
const mockAddress = '0x1234567890123456789012345678901234567890';
vi.mocked(isBasename).mockReturnValue(false);
vi.mocked(isEns).mockReturnValue(false);
vi.mocked(isAddress).mockReturnValue(true);

const result = await validateAddressInput(mockAddress);

expect(isAddress).toHaveBeenCalledWith(mockAddress, { strict: false });
expect(result).toBe(mockAddress);
});

it('returns null for invalid input', async () => {
vi.mocked(isBasename).mockReturnValue(false);
vi.mocked(isEns).mockReturnValue(false);
vi.mocked(isAddress).mockReturnValue(false);

const result = await validateAddressInput('invalid input');

expect(result).toBeNull();
});
});
23 changes: 23 additions & 0 deletions src/wallet/utils/validateAddressInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { getAddress } from '@/identity/utils/getAddress';
import { isBasename } from '@/identity/utils/isBasename';
import { isEns } from '@/identity/utils/isEns';
import { isAddress } from 'viem';
import { base, mainnet } from 'viem/chains';

export async function validateAddressInput(input: string) {
if (isAddress(input, { strict: false })) {
return input;
}

if (isBasename(input) || isEns(input)) {
const address = await getAddress({
name: input,
chain: isBasename(input) ? base : mainnet,
});
if (address) {
return address;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: if isAddress then no need to check if its isBasename or isEns? moving this first could remove the else if and short circuit faster

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

makes sense, updated


return null;
}