diff --git a/.changeset/olive-trees-play.md b/.changeset/olive-trees-play.md new file mode 100644 index 00000000000..5cf94decba4 --- /dev/null +++ b/.changeset/olive-trees-play.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Optimize ERC20 transferBatch diff --git a/packages/thirdweb/src/extensions/erc20/write/transferBatch.test.ts b/packages/thirdweb/src/extensions/erc20/write/transferBatch.test.ts index fd1be09feb0..9f8efca7f5c 100644 --- a/packages/thirdweb/src/extensions/erc20/write/transferBatch.test.ts +++ b/packages/thirdweb/src/extensions/erc20/write/transferBatch.test.ts @@ -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"; @@ -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, @@ -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, }); @@ -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, + }, ], }), }); @@ -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, + }, + ]); + }); }); diff --git a/packages/thirdweb/src/extensions/erc20/write/transferBatch.ts b/packages/thirdweb/src/extensions/erc20/write/transferBatch.ts index 5088c9efde9..d5891e1d872 100644 --- a/packages/thirdweb/src/extensions/erc20/write/transferBatch.ts +++ b/packages/thirdweb/src/extensions/erc20/write/transferBatch.ts @@ -53,34 +53,88 @@ export function transferBatch( 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, +): Promise> { + 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}`, + ); + } + 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; +}