Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/olive-trees-play.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Optimize ERC20 transferBatch
112 changes: 102 additions & 10 deletions packages/thirdweb/src/extensions/erc20/write/transferBatch.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
import { ANVIL_CHAIN } from "~test/chains.js";
import { TEST_CONTRACT_URI } from "~test/ipfs-uris.js";
import { TEST_CLIENT } from "~test/test-clients.js";
Expand All @@ -8,19 +8,23 @@ import {
TEST_ACCOUNT_C,
TEST_ACCOUNT_D,
} from "~test/test-wallets.js";
import { getContract } from "../../../contract/contract.js";
import {
type ThirdwebContract,
getContract,
} from "../../../contract/contract.js";
import { deployERC20Contract } from "../../../extensions/prebuilts/deploy-erc20.js";
import { sendAndConfirmTransaction } from "../../../transaction/actions/send-and-confirm-transaction.js";
import { balanceOf } from "../__generated__/IERC20/read/balanceOf.js";
import { mintTo } from "./mintTo.js";
import { transferBatch } from "./transferBatch.js";
import { optimizeTransferContent, transferBatch } from "./transferBatch.js";

const chain = ANVIL_CHAIN;
const client = TEST_CLIENT;
const account = TEST_ACCOUNT_A;
let contract: ThirdwebContract;

describe.runIf(process.env.TW_SECRET_KEY)("erc20: transferBatch", () => {
it("should transfer tokens to multiple recipients", async () => {
beforeAll(async () => {
const address = await deployERC20Contract({
type: "TokenERC20",
account,
Expand All @@ -31,15 +35,16 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc20: transferBatch", () => {
contractURI: TEST_CONTRACT_URI,
},
});
const contract = getContract({
contract = getContract({
address,
chain,
client,
});

// Mint 100 tokens
}, 60_000_000);
it("should transfer tokens to multiple recipients", async () => {
// Mint 200 tokens
await sendAndConfirmTransaction({
transaction: mintTo({ contract, to: account.address, amount: 100 }),
transaction: mintTo({ contract, to: account.address, amount: 200 }),
account,
});

Expand All @@ -61,6 +66,14 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc20: transferBatch", () => {
to: TEST_ACCOUNT_D.address,
amount: 25,
},
{
to: TEST_ACCOUNT_B.address.toLowerCase(),
amount: 25,
},
{
to: TEST_ACCOUNT_B.address,
amountWei: 25n * 10n ** 18n,
},
],
}),
});
Expand All @@ -73,9 +86,88 @@ describe.runIf(process.env.TW_SECRET_KEY)("erc20: transferBatch", () => {
balanceOf({ contract, address: TEST_ACCOUNT_D.address }),
]);

expect(balanceA).toBe(25n * 10n ** 18n);
expect(balanceB).toBe(25n * 10n ** 18n);
expect(balanceA).toBe(75n * 10n ** 18n);
expect(balanceB).toBe(75n * 10n ** 18n);
expect(balanceC).toBe(25n * 10n ** 18n);
expect(balanceD).toBe(25n * 10n ** 18n);
});

it("should optimize the transfer content", async () => {
const content = await optimizeTransferContent({
contract,
batch: [
{
to: TEST_ACCOUNT_B.address,
amount: 25,
},
{
to: TEST_ACCOUNT_C.address,
amount: 25,
},
{
to: TEST_ACCOUNT_D.address,
amount: 25,
},
{
// Should work
to: TEST_ACCOUNT_B.address.toLowerCase(),
amount: 25,
},
{
to: TEST_ACCOUNT_B.address,
amountWei: 25n * 10n ** 18n,
},
],
});

expect(content).toStrictEqual([
{
to: TEST_ACCOUNT_B.address,
amountWei: 75n * 10n ** 18n,
},
{
to: TEST_ACCOUNT_C.address,
amountWei: 25n * 10n ** 18n,
},
{
to: TEST_ACCOUNT_D.address,
amountWei: 25n * 10n ** 18n,
},
]);
});

it("an already-optimized content should not be changed", async () => {
const content = await optimizeTransferContent({
contract,
batch: [
{
to: TEST_ACCOUNT_B.address,
amountWei: 25n * 10n ** 18n,
},
{
to: TEST_ACCOUNT_C.address,
amount: 25,
},
{
to: TEST_ACCOUNT_D.address,
amount: 25,
},
],
});

expect(content).toStrictEqual([
{
to: TEST_ACCOUNT_B.address,
amountWei: 25n * 10n ** 18n,
},
{
to: TEST_ACCOUNT_C.address,
amountWei: 25n * 10n ** 18n,
},
{
to: TEST_ACCOUNT_D.address,
amountWei: 25n * 10n ** 18n,
},
]);
});
});
104 changes: 79 additions & 25 deletions packages/thirdweb/src/extensions/erc20/write/transferBatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,34 +53,88 @@
return multicall({
contract: options.contract,
asyncParams: async () => {
const content = await optimizeTransferContent(options);
return {
data: await Promise.all(
options.batch.map(async (transfer) => {
let amount: bigint;
if ("amount" in transfer) {
// if we need to parse the amount from ether to gwei then we pull in the decimals extension
const { decimals } = await import("../read/decimals.js");
// it's OK to call this multiple times because the call is cached
// if this fails we fall back to `18` decimals
const d = await decimals(options).catch(() => 18);
// turn ether into gwei
amount = toUnits(transfer.amount.toString(), d);
} else {
amount = transfer.amountWei;
}
return encodeTransfer({
to: transfer.to,
value: amount,
overrides: {
erc20Value: {
amountWei: amount,
tokenAddress: options.contract.address,
},
data: content.map((item) => {
return encodeTransfer({
to: item.to,
value: item.amountWei,
overrides: {
erc20Value: {
amountWei: item.amountWei,
tokenAddress: options.contract.address,
},
});
}),
),
},
});
}),
};
},
});
}

/**
* Records with the same recipient (`to`) can be packed into one transaction
* For example, the data below:
* ```ts
* [
* {
* to: "wallet-a",
* amount: 1,
* },
* {
* to: "wallet-A",
* amountWei: 1000000000000000000n,
* },
* ]
* ```
*
* can be packed to:
* ```ts
* [
* {
* to: "wallet-a",
* amountWei: 2000000000000000000n,
* },
* ]
* ```
* @internal
*/
export async function optimizeTransferContent(
options: BaseTransactionOptions<TransferBatchParams>,
): Promise<Array<{ to: string; amountWei: bigint }>> {
const groupedRecords = await options.batch.reduce(
async (accPromise, record) => {
const acc = await accPromise;
let amountInWei: bigint;
if ("amount" in record) {
// it's OK to call this multiple times because the call is cached
const { decimals } = await import("../read/decimals.js");
// if this fails we fall back to `18` decimals
const d = await decimals(options).catch(() => undefined);
if (d === undefined) {
throw new Error(
`Failed to get the decimals for contract: ${options.contract.address}`,
);
}

Check warning on line 118 in packages/thirdweb/src/extensions/erc20/write/transferBatch.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/extensions/erc20/write/transferBatch.ts#L115-L118

Added lines #L115 - L118 were not covered by tests
amountInWei = toUnits(record.amount.toString(), d);
} else {
amountInWei = record.amountWei;
}
const existingRecord = acc.find(
(r) => r.to.toLowerCase() === record.to.toLowerCase(),
);
if (existingRecord) {
existingRecord.amountWei = existingRecord.amountWei + amountInWei;
} else {
acc.push({
to: record.to,
amountWei: amountInWei,
});
}

return acc;
},
Promise.resolve([] as { to: string; amountWei: bigint }[]),
);
return groupedRecords;
}
Loading