Skip to content

Commit 65e4ddc

Browse files
feat: add erc721 claim conditions + override list support (#2766)
Signed-off-by: Jonas Daniels <jonas.daniels@outlook.com> Co-authored-by: Jonas Daniels <jonas.daniels@outlook.com>
1 parent 955a207 commit 65e4ddc

File tree

9 files changed

+455
-100
lines changed

9 files changed

+455
-100
lines changed

.changeset/sixty-rabbits-suffer.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"thirdweb": minor
3+
---
4+
5+
Added ERC721 extensions:
6+
7+
- claimTo with allowlist support
8+
- lazyMint
9+
- setClaimConditions

packages/thirdweb/src/extensions/erc1155/drops/write/claimTo.ts

Lines changed: 7 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import type { Address } from "abitype";
2-
import { maxUint256 } from "viem";
3-
import {
4-
ADDRESS_ZERO,
5-
isNativeTokenAddress,
6-
} from "../../../../constants/addresses.js";
72
import type { BaseTransactionOptions } from "../../../../transaction/types.js";
8-
import { padHex } from "../../../../utils/encoding/hex.js";
9-
import type { OverrideProof } from "../../../../utils/extensions/drops/types.js";
3+
import { getClaimParams } from "../../../../utils/extensions/drops/get-claim-params.js";
104
import { claim } from "../../__generated__/IDrop1155/write/claim.js";
11-
import { getActiveClaimCondition } from "../read/getActiveClaimCondition.js";
125

136
export type ClaimToParams = {
147
to: Address;
@@ -39,71 +32,18 @@ export function claimTo(options: BaseTransactionOptions<ClaimToParams>) {
3932
return claim({
4033
contract: options.contract,
4134
async asyncParams() {
42-
const cc = await getActiveClaimCondition({
35+
const params = await getClaimParams({
36+
type: "erc1155",
4337
contract: options.contract,
38+
to: options.to,
39+
quantity: options.quantity,
40+
from: options.from,
4441
tokenId: options.tokenId,
4542
});
4643

47-
// compute the allowListProof in an iife
48-
const allowlistProof = await (async () => {
49-
// early exit if no merkle root is set
50-
if (!cc.merkleRoot || cc.merkleRoot === padHex("0x", { size: 32 })) {
51-
return {
52-
currency: ADDRESS_ZERO,
53-
proof: [],
54-
quantityLimitPerWallet: 0n,
55-
pricePerToken: 0n,
56-
} satisfies OverrideProof;
57-
}
58-
// lazy-load the fetchProofsForClaimer function if we need it
59-
const { fetchProofsForClaimer } = await import(
60-
"../../../../utils/extensions/drops/fetch-proofs-for-claimers.js"
61-
);
62-
63-
const allowListProof = await fetchProofsForClaimer({
64-
contract: options.contract,
65-
claimer: options.from || options.to, // receiver and claimer can be different, always prioritize the claimer for allowlists
66-
merkleRoot: cc.merkleRoot,
67-
tokenDecimals: 0, // nfts have no decimals
68-
});
69-
// if no proof is found, we'll try the empty proof
70-
if (!allowListProof) {
71-
return {
72-
currency: ADDRESS_ZERO,
73-
proof: [],
74-
quantityLimitPerWallet: 0n,
75-
pricePerToken: 0n,
76-
} satisfies OverrideProof;
77-
}
78-
// otherwise return the proof
79-
return allowListProof;
80-
})();
81-
82-
// currency and price need to match the allowlist proof if set
83-
// if default values in the allowlist proof, fallback to the claim condition
84-
const currency =
85-
allowlistProof.currency && allowlistProof.currency !== ADDRESS_ZERO
86-
? allowlistProof.currency
87-
: cc.currency;
88-
const pricePerToken =
89-
allowlistProof.pricePerToken !== undefined &&
90-
allowlistProof.pricePerToken !== maxUint256
91-
? allowlistProof.pricePerToken
92-
: cc.pricePerToken;
93-
9444
return {
95-
receiver: options.to,
45+
...params,
9646
tokenId: options.tokenId,
97-
quantity: options.quantity,
98-
currency,
99-
pricePerToken,
100-
allowlistProof,
101-
data: "0x",
102-
overrides: {
103-
value: isNativeTokenAddress(currency)
104-
? pricePerToken * BigInt(options.quantity)
105-
: 0n,
106-
},
10747
};
10848
},
10949
});

packages/thirdweb/src/extensions/erc1155/drops/write/setClaimConditions.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function setClaimConditions(
4545
phases: options.phases,
4646
resetClaimEligibility: options.resetClaimEligibility,
4747
tokenId: options.tokenId,
48+
tokenDecimals: 0,
4849
}),
4950
};
5051
},
Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
1+
import { beforeAll, describe, expect, it } from "vitest";
2+
import { VITALIK_WALLET } from "../../../test/src/addresses.js";
3+
import { ANVIL_CHAIN } from "../../../test/src/chains.js";
4+
import { TEST_CLIENT } from "../../../test/src/test-clients.js";
5+
import {
6+
TEST_ACCOUNT_A,
7+
TEST_ACCOUNT_B,
8+
} from "../../../test/src/test-wallets.js";
9+
import { type ThirdwebContract, getContract } from "../../contract/contract.js";
10+
import { sendAndConfirmTransaction } from "../../exports/transaction.js";
11+
import { getContractMetadata } from "../common/read/getContractMetadata.js";
12+
import { deployERC721Contract } from "../prebuilts/deploy-erc721.js";
13+
import { balanceOf } from "./__generated__/IERC721A/read/balanceOf.js";
14+
import { nextTokenIdToMint } from "./__generated__/IERC721Enumerable/read/nextTokenIdToMint.js";
15+
import { claimTo } from "./drops/write/claimTo.js";
16+
import { setClaimConditions } from "./drops/write/setClaimConditions.js";
17+
import { getNFT } from "./read/getNFT.js";
18+
import { lazyMint } from "./write/lazyMint.js";
19+
20+
describe.runIf(process.env.TW_SECRET_KEY)(
21+
"DropERC721",
22+
{
23+
retry: 0,
24+
},
25+
() => {
26+
let contract: ThirdwebContract;
27+
28+
beforeAll(async () => {
29+
const contractAddress = await deployERC721Contract({
30+
account: TEST_ACCOUNT_A,
31+
chain: ANVIL_CHAIN,
32+
client: TEST_CLIENT,
33+
params: {
34+
name: "Test DropERC721",
35+
},
36+
type: "DropERC721",
37+
});
38+
39+
contract = getContract({
40+
address: contractAddress,
41+
chain: ANVIL_CHAIN,
42+
client: TEST_CLIENT,
43+
});
44+
// this deploys a contract, it may take some time
45+
}, 60_000);
46+
47+
describe("Deployment", () => {
48+
it("should deploy", async () => {
49+
expect(contract).toBeDefined();
50+
});
51+
it("should have the correct name", async () => {
52+
const metadata = await getContractMetadata({ contract });
53+
expect(metadata.name).toBe("Test DropERC721");
54+
});
55+
});
56+
57+
it("should allow for lazy minting tokens", async () => {
58+
const mintTx = lazyMint({
59+
contract,
60+
nfts: [
61+
{ name: "Test NFT" },
62+
{ name: "Test NFT 2" },
63+
{ name: "Test NFT 3" },
64+
{ name: "Test NFT 4" },
65+
],
66+
});
67+
await sendAndConfirmTransaction({
68+
transaction: mintTx,
69+
account: TEST_ACCOUNT_A,
70+
});
71+
72+
await expect(nextTokenIdToMint({ contract })).resolves.toBe(4n);
73+
await expect(
74+
getNFT({ contract, tokenId: 0n }),
75+
).resolves.toMatchInlineSnapshot(`
76+
{
77+
"id": 0n,
78+
"metadata": {
79+
"name": "Test NFT",
80+
},
81+
"owner": null,
82+
"tokenURI": "ipfs://QmUfspS2uU9roYLJveebbY5geYaNR4KkZAsMkb5pPRtc7a/0",
83+
"type": "ERC721",
84+
}
85+
`);
86+
});
87+
88+
it("should allow to claim tokens", async () => {
89+
await expect(
90+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
91+
).resolves.toBe(0n);
92+
await sendAndConfirmTransaction({
93+
transaction: setClaimConditions({
94+
contract,
95+
phases: [{}],
96+
}),
97+
account: TEST_ACCOUNT_A,
98+
});
99+
const claimTx = claimTo({
100+
contract,
101+
to: TEST_ACCOUNT_A.address,
102+
quantity: 1n,
103+
});
104+
await sendAndConfirmTransaction({
105+
transaction: claimTx,
106+
account: TEST_ACCOUNT_A,
107+
});
108+
await expect(
109+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
110+
).resolves.toBe(1n);
111+
});
112+
113+
describe("Allowlists", () => {
114+
it("should allow to claim tokens with an allowlist", async () => {
115+
await sendAndConfirmTransaction({
116+
transaction: setClaimConditions({
117+
contract,
118+
phases: [
119+
{
120+
overrideList: [
121+
{ address: TEST_ACCOUNT_A.address, maxClaimable: "100" },
122+
{ address: VITALIK_WALLET, maxClaimable: "100" },
123+
],
124+
maxClaimablePerWallet: 0n,
125+
},
126+
],
127+
}),
128+
account: TEST_ACCOUNT_A,
129+
});
130+
131+
await expect(
132+
balanceOf({ contract, owner: TEST_ACCOUNT_B.address }),
133+
).resolves.toBe(0n);
134+
135+
await sendAndConfirmTransaction({
136+
account: TEST_ACCOUNT_A,
137+
transaction: claimTo({
138+
contract,
139+
from: TEST_ACCOUNT_A.address,
140+
to: TEST_ACCOUNT_B.address,
141+
quantity: 1n,
142+
}),
143+
});
144+
145+
await expect(
146+
balanceOf({ contract, owner: TEST_ACCOUNT_B.address }),
147+
).resolves.toBe(1n);
148+
149+
await expect(
150+
sendAndConfirmTransaction({
151+
account: TEST_ACCOUNT_B,
152+
transaction: claimTo({
153+
contract,
154+
to: TEST_ACCOUNT_B.address,
155+
quantity: 1n,
156+
}),
157+
}),
158+
).rejects.toThrowErrorMatchingInlineSnapshot(`
159+
[TransactionError: Error - !Qty
160+
161+
contract: ${contract.address}
162+
chainId: 31337]
163+
`);
164+
});
165+
166+
it("should respect max claimable", async () => {
167+
await sendAndConfirmTransaction({
168+
transaction: setClaimConditions({
169+
contract,
170+
phases: [
171+
{
172+
overrideList: [
173+
{ address: TEST_ACCOUNT_A.address, maxClaimable: "3" },
174+
{ address: VITALIK_WALLET, maxClaimable: "3" },
175+
],
176+
maxClaimablePerWallet: 0n,
177+
},
178+
],
179+
}),
180+
account: TEST_ACCOUNT_A,
181+
});
182+
183+
await expect(
184+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
185+
).resolves.toBe(1n);
186+
187+
// we try to claim an extra `2` tokens
188+
// this should faile bcause the max claimable is `3` and we have previously already claimed 2 tokens (one for ourselves, one for the other wallet)
189+
// NOTE: this relies on the previous tests, we should extract this and properly re-set tests every time
190+
// this probably requires re-deploying contracts for every test => clean slate
191+
await expect(
192+
sendAndConfirmTransaction({
193+
account: TEST_ACCOUNT_A,
194+
transaction: claimTo({
195+
contract,
196+
to: TEST_ACCOUNT_A.address,
197+
quantity: 2n,
198+
}),
199+
}),
200+
).rejects.toThrowErrorMatchingInlineSnapshot(`
201+
[TransactionError: Error - !Qty
202+
203+
contract: ${contract.address}
204+
chainId: 31337]
205+
`);
206+
207+
// we now try to claim just ONE more token
208+
// this should work because we have only claimed `2` tokens so far (one for ourselves, one for the other wallet)
209+
// this should work because the max claimable is `3` and so we **can** claim `1` more token
210+
await sendAndConfirmTransaction({
211+
account: TEST_ACCOUNT_A,
212+
transaction: claimTo({
213+
contract,
214+
to: TEST_ACCOUNT_A.address,
215+
quantity: 1n,
216+
}),
217+
});
218+
219+
await expect(
220+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
221+
).resolves.toBe(2n);
222+
});
223+
});
224+
225+
it("should respect price", async () => {
226+
await sendAndConfirmTransaction({
227+
transaction: setClaimConditions({
228+
contract,
229+
phases: [
230+
{
231+
overrideList: [
232+
{
233+
address: TEST_ACCOUNT_A.address,
234+
maxClaimable: "10",
235+
price: "0",
236+
},
237+
],
238+
maxClaimablePerWallet: 0n,
239+
price: "1000",
240+
},
241+
],
242+
}),
243+
account: TEST_ACCOUNT_A,
244+
});
245+
246+
await expect(
247+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
248+
).resolves.toBe(2n);
249+
250+
await sendAndConfirmTransaction({
251+
account: TEST_ACCOUNT_A,
252+
transaction: claimTo({
253+
contract,
254+
to: TEST_ACCOUNT_A.address,
255+
quantity: 1n,
256+
}),
257+
});
258+
259+
await expect(
260+
balanceOf({ contract, owner: TEST_ACCOUNT_A.address }),
261+
).resolves.toBe(3n);
262+
});
263+
},
264+
);

0 commit comments

Comments
 (0)