Skip to content
Open
Show file tree
Hide file tree
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
50 changes: 39 additions & 11 deletions components/contract/SendTokens.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ import {

import { isAddress } from 'essential-eth';
import { useAtom } from 'jotai';
import { encodeFunctionData, erc20Abi } from 'viem';
import { erc20Abi } from 'viem';
import { checkedTokensAtom } from '../../src/atoms/checked-tokens-atom';
import { destinationAddressAtom } from '../../src/atoms/destination-address-atom';
import { buildTokenCalls, CallableToken } from '../../src/build-token-calls';
import { globalTokensAtom } from '../../src/atoms/global-tokens-atom';

export const SendTokens = () => {
Expand Down Expand Up @@ -104,14 +105,37 @@ export const SendTokens = () => {
tokensToSend: ReadonlyArray<`0x${string}`>,
toAddress: `0x${string}`,
) => {
const calls = tokensToSend.map((tokenAddress) => ({
to: tokenAddress,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [toAddress, tokenBalance(tokenAddress)],
}),
}));
if (!publicClient) return;

// The default token (ETH, MATIC, AVAX, …) is the chain's native currency,
// not an ERC-20 contract. Encoding it as a `transfer()` call against its
// pseudo-address would revert the whole atomic batch, so buildTokenCalls
// moves it with a plain value transfer instead (mirroring the sequential
// fallback fixed in b875374). Gas price feeds the native gas reserve.
const gasPrice = await publicClient.getGasPrice();
const callableTokens: CallableToken[] = tokensToSend.map((tokenAddress) => {
const token = tokens.find(
(token) => token.contract_address === tokenAddress,
);
return {
contract_address: tokenAddress,
balance: token?.balance || '0',
native_token: Boolean(token?.native_token),
contract_ticker_symbol: token?.contract_ticker_symbol,
};
});

const { calls, skipped } = buildTokenCalls(
callableTokens,
toAddress,
gasPrice,
);

// Surface any native tokens too small to cover their gas reserve.
skipped.forEach(({ symbol }) =>
showToast(`Not enough ${symbol || 'token'} to cover gas`, 'warning'),
);
if (calls.length === 0) return;

// DIAGNOSTIC: what does the wallet say it can do for this chain?
console.log('[drain] chainId', chainId);
Expand All @@ -122,9 +146,13 @@ export const SendTokens = () => {
);

const { id } = await sendCallsAsync({ calls, forceAtomic: true });
tokensToSend.forEach((tokenAddress) => markPending(tokenAddress, id));
const skippedAddresses = new Set(skipped.map(({ address }) => address));
const sentTokens = tokensToSend.filter(
(tokenAddress) => !skippedAddresses.has(tokenAddress),
);
sentTokens.forEach((tokenAddress) => markPending(tokenAddress, id));
showToast(
`Batched ${tokensToSend.length} transfers into one transaction`,
`Batched ${sentTokens.length} transfers into one transaction`,
'success',
);
};
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import './.next/dev/types/routes.d.ts';
import './.next/types/routes.d.ts';

// NOTE: This file should not be edited
// see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.
44 changes: 0 additions & 44 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

121 changes: 121 additions & 0 deletions src/build-token-calls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { describe, expect, it } from 'vitest';
import { decodeFunctionData, erc20Abi } from 'viem';
import {
buildTokenCalls,
CallableToken,
NATIVE_GAS_RESERVE_UNITS,
} from './build-token-calls';

const DEST = '0x1111111111111111111111111111111111111111' as const;
// The pseudo-address the app uses to represent a chain's native currency.
const NATIVE_ADDRESS =
'0xeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee' as `0x${string}`;
const DAI = '0x6b175474e89094c44da98b954eedeac495271d0f' as `0x${string}`;

const gasPrice = BigInt(1_000_000_000); // 1 gwei

describe('buildTokenCalls', () => {
it('encodes an ERC-20 token as a transfer() call to the token contract', () => {
const tokens: CallableToken[] = [
{
contract_address: DAI,
balance: '5000000000000000000',
native_token: false,
contract_ticker_symbol: 'DAI',
},
];

const { calls, skipped } = buildTokenCalls(tokens, DEST, gasPrice);

expect(skipped).toEqual([]);
expect(calls).toHaveLength(1);
expect(calls[0].to).toBe(DAI);
expect(calls[0].value).toBeUndefined();

const decoded = decodeFunctionData({
abi: erc20Abi,
data: calls[0].data!,
});
expect(decoded.functionName).toBe('transfer');
expect(decoded.args[0]).toBe(DEST);
expect(decoded.args[1]).toBe(BigInt('5000000000000000000'));
});

it('sends the native token as a value transfer, not an ERC-20 call', () => {
const balance = BigInt('1000000000000000000'); // 1 ETH
const tokens: CallableToken[] = [
{
contract_address: NATIVE_ADDRESS,
balance: balance.toString(),
native_token: true,
contract_ticker_symbol: 'ETH',
},
];

const { calls, skipped } = buildTokenCalls(tokens, DEST, gasPrice);

expect(skipped).toEqual([]);
expect(calls).toHaveLength(1);
// Value transfer goes to the destination, carries no calldata, and never
// targets the native pseudo-address (which has no transfer() method).
expect(calls[0].to).toBe(DEST);
expect(calls[0].data).toBeUndefined();
expect(calls[0].value).toBe(balance - gasPrice * NATIVE_GAS_RESERVE_UNITS);
});

it('handles a mix of native + ERC-20 tokens in one batch', () => {
const tokens: CallableToken[] = [
{
contract_address: NATIVE_ADDRESS,
balance: '1000000000000000000',
native_token: true,
contract_ticker_symbol: 'ETH',
},
{
contract_address: DAI,
balance: '250000000000000000',
native_token: false,
contract_ticker_symbol: 'DAI',
},
];

const { calls, skipped } = buildTokenCalls(tokens, DEST, gasPrice);

expect(skipped).toEqual([]);
expect(calls).toHaveLength(2);

// Native: value transfer to destination, no calldata.
expect(calls[0].to).toBe(DEST);
expect(calls[0].data).toBeUndefined();
expect(calls[0].value).toBeGreaterThan(BigInt(0));

// ERC-20: encoded transfer to the token contract, no value.
expect(calls[1].to).toBe(DAI);
expect(calls[1].value).toBeUndefined();
const decoded = decodeFunctionData({
abi: erc20Abi,
data: calls[1].data!,
});
expect(decoded.functionName).toBe('transfer');
expect(decoded.args[0]).toBe(DEST);
expect(decoded.args[1]).toBe(BigInt('250000000000000000'));
});

it('skips a native token that cannot cover the reserved gas', () => {
// Balance smaller than the reserved gas → nothing left to send.
const tinyBalance = (gasPrice * NATIVE_GAS_RESERVE_UNITS) / BigInt(2);
const tokens: CallableToken[] = [
{
contract_address: NATIVE_ADDRESS,
balance: tinyBalance.toString(),
native_token: true,
contract_ticker_symbol: 'ETH',
},
];

const { calls, skipped } = buildTokenCalls(tokens, DEST, gasPrice);

expect(calls).toEqual([]);
expect(skipped).toEqual([{ address: NATIVE_ADDRESS, symbol: 'ETH' }]);
});
});
76 changes: 76 additions & 0 deletions src/build-token-calls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { encodeFunctionData, erc20Abi } from 'viem';

// A single call in an EIP-5792 batch. ERC-20 transfers carry `data` (an
// encoded `transfer` call) targeting the token contract; the chain's native
// currency is moved with a plain `value` transfer targeting the destination.
export interface BuiltCall {
to: `0x${string}`;
data?: `0x${string}`;
value?: bigint;
}

// The minimal shape of a token needed to build a transfer call.
export interface CallableToken {
contract_address: `0x${string}`;
balance: string;
native_token: boolean;
contract_ticker_symbol?: string;
}

export interface SkippedToken {
address: `0x${string}`;
symbol?: string;
}

export interface BuildCallsResult {
calls: BuiltCall[];
// Native tokens whose balance couldn't cover the reserved gas, so they were
// left out of the batch. Callers surface these to the user.
skipped: SkippedToken[];
}

// Reserve gas for a standard 21000-gas value transfer, with a 2x buffer for
// base-fee movement between building the batch and its inclusion. Mirrors the
// sequential fallback path so both routes reason about gas identically.
export const NATIVE_GAS_RESERVE_UNITS = BigInt(21000) * BigInt(2);

// Build the list of calls for an EIP-5792 batch. The chain's native token
// (ETH, MATIC, AVAX, …) is not an ERC-20 contract, so encoding it as a
// `transfer()` call against its pseudo-address (0xeee…) would revert the whole
// atomic batch. Instead we move it with a plain value transfer, reserving gas
// the same way the sequential fallback does.
export const buildTokenCalls = (
tokensToSend: ReadonlyArray<CallableToken>,
toAddress: `0x${string}`,
gasPrice: bigint,
): BuildCallsResult => {
const calls: BuiltCall[] = [];
const skipped: SkippedToken[] = [];

for (const token of tokensToSend) {
if (token.native_token) {
const gasReserve = gasPrice * NATIVE_GAS_RESERVE_UNITS;
const value = BigInt(token.balance) - gasReserve;
if (value <= BigInt(0)) {
skipped.push({
address: token.contract_address,
symbol: token.contract_ticker_symbol,
});
continue;
}
calls.push({ to: toAddress, value });
continue;
}

calls.push({
to: token.contract_address,
data: encodeFunctionData({
abi: erc20Abi,
functionName: 'transfer',
args: [toAddress, BigInt(token.balance)],
}),
});
}

return { calls, skipped };
};
1 change: 1 addition & 0 deletions tsconfig.tsbuildinfo

Large diffs are not rendered by default.