Skip to content

Commit c32576f

Browse files
committed
[SDK] ERC721 claimToBatch extension (#5124)
## Problem solved Short description of the bug fixed or feature added <!-- start pr-codex --> --- ## PR-Codex overview This PR introduces the `claimToBatch` functionality for the `ERC721` extension, allowing multiple claims in a single transaction. It includes tests to ensure the optimization of claim content and the correct processing of batch claims. ### Detailed summary - Added `claimToBatch` function in `claimToBatch.ts` for batch claiming NFTs. - Introduced `ClaimToBatchParams` type to define batch claim structure. - Implemented `optimizeClaimContent` function to consolidate identical recipient claims. - Created tests for `claimToBatch` and `optimizeClaimContent` in `claimToBatch.test.ts`. - Verified the functionality of `isClaimToSupported` in the test suite. > ✨ Ask PR-Codex anything about this PR by commenting with `/codex {your question}` <!-- end pr-codex -->
1 parent d522343 commit c32576f

File tree

4 files changed

+317
-0
lines changed

4 files changed

+317
-0
lines changed

packages/thirdweb/src/exports/extensions/erc721.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,7 @@ export {
213213
type UpdateTokenURIParams,
214214
isUpdateTokenURISupported,
215215
} from "../../extensions/erc721/write/updateTokenURI.js";
216+
export {
217+
claimToBatch,
218+
type ClaimToBatchParams,
219+
} from "../../extensions/erc721/drops/write/claimToBatch.js";
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { type Abi, toFunctionSelector } from "viem";
2+
import { describe, expect, it } from "vitest";
3+
import { NFT_DROP_CONTRACT } from "~test/test-contracts.js";
4+
import { resolveContractAbi } from "../../../../contract/actions/resolve-abi.js";
5+
import { isClaimToSupported } from "./claimTo.js";
6+
7+
describe.runIf(process.env.TW_SECRET_KEY)("ERC721: claimTo", () => {
8+
it("isClaimToSupported should work", async () => {
9+
const abi = await resolveContractAbi<Abi>(NFT_DROP_CONTRACT);
10+
const selectors = abi
11+
.filter((f) => f.type === "function")
12+
.map((f) => toFunctionSelector(f));
13+
expect(isClaimToSupported(selectors)).toBe(true);
14+
});
15+
});
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
import { describe, expect, it } from "vitest";
2+
import { ANVIL_CHAIN } from "~test/chains.js";
3+
import { TEST_CONTRACT_URI } from "~test/ipfs-uris.js";
4+
import { TEST_CLIENT } from "~test/test-clients.js";
5+
import {
6+
TEST_ACCOUNT_A,
7+
TEST_ACCOUNT_B,
8+
TEST_ACCOUNT_C,
9+
TEST_ACCOUNT_D,
10+
} from "~test/test-wallets.js";
11+
import { getContract } from "../../../../contract/contract.js";
12+
import { deployERC721Contract } from "../../../../extensions/prebuilts/deploy-erc721.js";
13+
import { sendAndConfirmTransaction } from "../../../../transaction/actions/send-and-confirm-transaction.js";
14+
import { getNFTs } from "../../read/getNFTs.js";
15+
import { lazyMint } from "../../write/lazyMint.js";
16+
import { claimToBatch, optimizeClaimContent } from "./claimToBatch.js";
17+
import { setClaimConditions } from "./setClaimConditions.js";
18+
19+
const chain = ANVIL_CHAIN;
20+
const client = TEST_CLIENT;
21+
const account = TEST_ACCOUNT_A;
22+
23+
describe.runIf(process.env.TW_SECRET_KEY)("erc721: claimToBatch", () => {
24+
it("should optimize the claim content", () => {
25+
expect(
26+
optimizeClaimContent([
27+
{
28+
to: account.address,
29+
quantity: 1n,
30+
},
31+
{
32+
to: TEST_ACCOUNT_B.address,
33+
quantity: 2n,
34+
},
35+
{
36+
to: TEST_ACCOUNT_B.address,
37+
quantity: 3n,
38+
},
39+
{ to: TEST_ACCOUNT_C.address, quantity: 2n },
40+
{ to: TEST_ACCOUNT_D.address, quantity: 2n },
41+
{ to: account.address, quantity: 1n },
42+
]),
43+
).toStrictEqual([
44+
{ to: account.address, quantity: 1n },
45+
{ to: TEST_ACCOUNT_B.address, quantity: 5n },
46+
{ to: TEST_ACCOUNT_C.address, quantity: 2n },
47+
{ to: TEST_ACCOUNT_D.address, quantity: 2n },
48+
{ to: account.address, quantity: 1n },
49+
]);
50+
});
51+
52+
it("should claim in batch", async () => {
53+
const address = await deployERC721Contract({
54+
account,
55+
chain,
56+
client,
57+
params: {
58+
name: "Test DropERC721",
59+
contractURI: TEST_CONTRACT_URI,
60+
},
61+
type: "DropERC721",
62+
});
63+
64+
const contract = getContract({
65+
address,
66+
chain,
67+
client,
68+
});
69+
70+
await sendAndConfirmTransaction({
71+
transaction: lazyMint({
72+
contract,
73+
nfts: [
74+
{ name: "Test NFT 0" },
75+
{ name: "Test NFT 1" },
76+
{ name: "Test NFT 2" },
77+
{ name: "Test NFT 3" },
78+
{ name: "Test NFT 4" },
79+
{ name: "Test NFT 5" },
80+
{ name: "Test NFT 6" },
81+
{ name: "Test NFT 7" },
82+
{ name: "Test NFT 8" },
83+
{ name: "Test NFT 9" },
84+
{ name: "Test NFT 10" },
85+
],
86+
}),
87+
account,
88+
});
89+
90+
await sendAndConfirmTransaction({
91+
transaction: setClaimConditions({
92+
contract,
93+
phases: [{}],
94+
}),
95+
account: TEST_ACCOUNT_A,
96+
});
97+
98+
const transaction = claimToBatch({
99+
contract,
100+
from: account.address,
101+
content: [
102+
{
103+
to: account.address,
104+
quantity: 1n,
105+
},
106+
{
107+
to: TEST_ACCOUNT_B.address,
108+
quantity: 2n,
109+
},
110+
{
111+
to: TEST_ACCOUNT_B.address,
112+
quantity: 3n,
113+
},
114+
{ to: TEST_ACCOUNT_C.address, quantity: 2n },
115+
{ to: TEST_ACCOUNT_D.address, quantity: 2n },
116+
{ to: account.address, quantity: 1n },
117+
],
118+
});
119+
120+
await sendAndConfirmTransaction({ account, transaction });
121+
122+
const nfts = await getNFTs({ contract, includeOwners: true });
123+
expect(nfts.length).toBe(11);
124+
expect(nfts[0]?.owner?.toLowerCase()).toBe(account.address.toLowerCase());
125+
expect(nfts[1]?.owner?.toLowerCase()).toBe(
126+
TEST_ACCOUNT_B.address.toLowerCase(),
127+
);
128+
expect(nfts[2]?.owner?.toLowerCase()).toBe(
129+
TEST_ACCOUNT_B.address.toLowerCase(),
130+
);
131+
expect(nfts[3]?.owner?.toLowerCase()).toBe(
132+
TEST_ACCOUNT_B.address.toLowerCase(),
133+
);
134+
expect(nfts[4]?.owner?.toLowerCase()).toBe(
135+
TEST_ACCOUNT_B.address.toLowerCase(),
136+
);
137+
expect(nfts[5]?.owner?.toLowerCase()).toBe(
138+
TEST_ACCOUNT_B.address.toLowerCase(),
139+
);
140+
expect(nfts[6]?.owner?.toLowerCase()).toBe(
141+
TEST_ACCOUNT_C.address.toLowerCase(),
142+
);
143+
expect(nfts[7]?.owner?.toLowerCase()).toBe(
144+
TEST_ACCOUNT_C.address.toLowerCase(),
145+
);
146+
expect(nfts[8]?.owner?.toLowerCase()).toBe(
147+
TEST_ACCOUNT_D.address.toLowerCase(),
148+
);
149+
expect(nfts[9]?.owner?.toLowerCase()).toBe(
150+
TEST_ACCOUNT_D.address.toLowerCase(),
151+
);
152+
expect(nfts[10]?.owner?.toLowerCase()).toBe(account.address.toLowerCase());
153+
});
154+
});
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import type { Address } from "abitype";
2+
import { multicall } from "../../../../extensions/common/__generated__/IMulticall/write/multicall.js";
3+
import type {
4+
BaseTransactionOptions,
5+
WithOverrides,
6+
} from "../../../../transaction/types.js";
7+
import { getClaimParams } from "../../../../utils/extensions/drops/get-claim-params.js";
8+
import { encodeClaim } from "../../__generated__/IDrop/write/claim.js";
9+
10+
/**
11+
* @extension ERC721
12+
*/
13+
export type ClaimToBatchParams = WithOverrides<{
14+
content: Array<{
15+
to: Address;
16+
quantity: bigint;
17+
}>;
18+
from?: Address;
19+
}>;
20+
21+
/**
22+
* This extension batches multiple `claimTo` extensions into one single multicall.
23+
* Keep in mind that there is a limit of how many NFTs you can claim per transaction.
24+
* This limit varies depends on the network that you are transacting on.
25+
*
26+
* You are recommended to experiment with the number to figure out the best number for your chain of choice.
27+
* @extension ERC721
28+
* @param options the transaction options
29+
* @returns A promise that resolves to the transaction result.
30+
*
31+
* @example
32+
* ```ts
33+
* import { claimToBatch } from "thirdweb/extensions/erc721";
34+
*
35+
* const transaction = claimToBatch({
36+
* contract: nftDropContract,
37+
* from: claimer.address, // address of the one calling this transaction
38+
* content: [
39+
* { to: "0x...1", quantity: 1n },
40+
* { to: "0x...2", quantity: 12n },
41+
* { to: "0x...3", quantity: 2n },
42+
* ],
43+
* });
44+
* ```
45+
*/
46+
export function claimToBatch(
47+
options: BaseTransactionOptions<ClaimToBatchParams>,
48+
) {
49+
return multicall({
50+
contract: options.contract,
51+
asyncParams: () => getClaimToBatchParams(options),
52+
overrides: options.overrides,
53+
});
54+
}
55+
56+
/**
57+
* @internal
58+
*/
59+
async function getClaimToBatchParams(
60+
options: BaseTransactionOptions<ClaimToBatchParams>,
61+
) {
62+
const errorIndexTo = options.content.findIndex((o) => !o.to);
63+
if (errorIndexTo !== -1) {
64+
throw new Error(
65+
`Error: Item at index ${errorIndexTo} is missing recipient address ("to")`,
66+
);
67+
}
68+
const errorIndexQuantity = options.content.findIndex((o) => !o.quantity);
69+
if (errorIndexQuantity !== -1) {
70+
throw new Error(
71+
`Error: Item at index ${errorIndexQuantity} is missing claim quantity`,
72+
);
73+
}
74+
const content = optimizeClaimContent(options.content);
75+
const data = await Promise.all(
76+
content.map(async (item) => {
77+
const claimParams = await getClaimParams({
78+
type: "erc721",
79+
contract: options.contract,
80+
to: item.to,
81+
from: options.from,
82+
quantity: item.quantity,
83+
});
84+
85+
return encodeClaim({
86+
receiver: claimParams.receiver,
87+
quantity: claimParams.quantity,
88+
currency: claimParams.currency,
89+
pricePerToken: claimParams.pricePerToken,
90+
allowlistProof: claimParams.allowlistProof,
91+
data: claimParams.data,
92+
});
93+
}),
94+
);
95+
96+
return { data };
97+
}
98+
99+
/**
100+
* Optimization
101+
* For identical addresses that stays next to each other in the array,
102+
* we can combine them into one transaction _without altering the claiming order_
103+
*
104+
* For exampple, this structure:
105+
* [
106+
* {
107+
* to: "0xabc",
108+
* quantity: 1n,
109+
* },
110+
* {
111+
* to: "0xabc",
112+
* quantity: 13n,
113+
* },
114+
* ];
115+
*
116+
* ...can be combined in one tx (without altering the claiming order)
117+
* {
118+
* to: "0xabc",
119+
* quantity: 14n,
120+
* }
121+
*
122+
* @internal
123+
*/
124+
export function optimizeClaimContent(
125+
content: Array<{ to: Address; quantity: bigint }>,
126+
): Array<{ to: Address; quantity: bigint }> {
127+
const results: Array<{ to: Address; quantity: bigint }> = [];
128+
content.forEach((item, index) => {
129+
const previousItem = results.at(-1);
130+
if (
131+
index > 0 &&
132+
previousItem &&
133+
item.to.toLowerCase() === previousItem.to.toLowerCase()
134+
) {
135+
results[results.length - 1] = {
136+
to: item.to,
137+
quantity: item.quantity + previousItem.quantity,
138+
};
139+
} else {
140+
results.push(item);
141+
}
142+
});
143+
return results;
144+
}

0 commit comments

Comments
 (0)