Skip to content

Commit ee80d87

Browse files
committed
Implement fully automated Merkle Proof generation for Claims
- Added MerkleTreeUtils for local proof calculation from shards - Added GetAllowlistProof extension method to fetch and generate proofs - Updated DropERC20, DropERC721, and DropERC1155 Claim methods to use dynamic proofs - Added necessary data types in ThirdwebExtensions.Types.cs - Fixed global.json to support available .NET 8 SDK - Optimized string comparisons for performance
1 parent aca31e2 commit ee80d87

4 files changed

Lines changed: 595 additions & 8 deletions

File tree

‎Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,132 @@ public class ContractMetadata
7676
/// </summary>
7777
[JsonProperty("image")]
7878
public string Image { get; set; }
79+
80+
/// <summary>
81+
/// Gets or sets the merkle tree mappings for claim conditions.
82+
/// Key: Merkle root hash, Value: IPFS URI to tree info
83+
/// </summary>
84+
[JsonProperty("merkle")]
85+
public Dictionary<string, string> Merkle { get; set; }
86+
}
87+
88+
#endregion
89+
90+
#region Merkle
91+
92+
/// <summary>
93+
/// Represents information about a sharded Merkle tree stored on IPFS.
94+
/// </summary>
95+
public class MerkleTreeInfo
96+
{
97+
/// <summary>
98+
/// Gets or sets the Merkle root hash.
99+
/// </summary>
100+
[JsonProperty("merkleRoot")]
101+
public string MerkleRoot { get; set; }
102+
103+
/// <summary>
104+
/// Gets or sets the base IPFS URI for shard files.
105+
/// </summary>
106+
[JsonProperty("baseUri")]
107+
public string BaseUri { get; set; }
108+
109+
/// <summary>
110+
/// Gets or sets the original entries IPFS URI.
111+
/// </summary>
112+
[JsonProperty("originalEntriesUri")]
113+
public string OriginalEntriesUri { get; set; }
114+
115+
/// <summary>
116+
/// Gets or sets the number of hex characters used for shard keys.
117+
/// </summary>
118+
[JsonProperty("shardNybbles")]
119+
public int ShardNybbles { get; set; } = 2;
120+
121+
/// <summary>
122+
/// Gets or sets the token decimals for price calculations.
123+
/// </summary>
124+
[JsonProperty("tokenDecimals")]
125+
public int TokenDecimals { get; set; } = 0;
126+
}
127+
128+
/// <summary>
129+
/// Represents a shard file containing whitelist entries and proofs.
130+
/// </summary>
131+
public class ShardData
132+
{
133+
/// <summary>
134+
/// Gets or sets the shard proofs (path from shard root to main root).
135+
/// </summary>
136+
[JsonProperty("proofs")]
137+
public List<string> Proofs { get; set; }
138+
139+
/// <summary>
140+
/// Gets or sets the whitelist entries in this shard.
141+
/// </summary>
142+
[JsonProperty("entries")]
143+
public List<WhitelistEntry> Entries { get; set; }
144+
}
145+
146+
/// <summary>
147+
/// Represents a whitelist entry for a claim condition.
148+
/// </summary>
149+
public class WhitelistEntry
150+
{
151+
/// <summary>
152+
/// Gets or sets the wallet address.
153+
/// </summary>
154+
[JsonProperty("address")]
155+
public string Address { get; set; }
156+
157+
/// <summary>
158+
/// Gets or sets the maximum claimable amount ("unlimited" or a number).
159+
/// </summary>
160+
[JsonProperty("maxClaimable")]
161+
public string MaxClaimable { get; set; }
162+
163+
/// <summary>
164+
/// Gets or sets the price override ("unlimited" or a number in wei).
165+
/// </summary>
166+
[JsonProperty("price")]
167+
public string Price { get; set; }
168+
169+
/// <summary>
170+
/// Gets or sets the currency address for price override.
171+
/// </summary>
172+
[JsonProperty("currencyAddress")]
173+
public string CurrencyAddress { get; set; }
174+
}
175+
176+
/// <summary>
177+
/// Represents an allowlist proof for claiming tokens.
178+
/// </summary>
179+
[FunctionOutput]
180+
public class AllowlistProof
181+
{
182+
/// <summary>
183+
/// Gets or sets the Merkle proof (array of bytes32 hashes).
184+
/// </summary>
185+
[Parameter("bytes32[]", "proof", 1)]
186+
public List<byte[]> Proof { get; set; }
187+
188+
/// <summary>
189+
/// Gets or sets the maximum quantity this address can claim.
190+
/// </summary>
191+
[Parameter("uint256", "quantityLimitPerWallet", 2)]
192+
public BigInteger QuantityLimitPerWallet { get; set; }
193+
194+
/// <summary>
195+
/// Gets or sets the price per token override.
196+
/// </summary>
197+
[Parameter("uint256", "pricePerToken", 3)]
198+
public BigInteger PricePerToken { get; set; }
199+
200+
/// <summary>
201+
/// Gets or sets the currency address for the price override.
202+
/// </summary>
203+
[Parameter("address", "currency", 4)]
204+
public string Currency { get; set; }
79205
}
80206

81207
#endregion

‎Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs‎

Lines changed: 229 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1555,6 +1555,157 @@ public static async Task<List<NFT>> ERC1155_GetOwnedNFTs(this ThirdwebContract c
15551555

15561556
#endregion
15571557

1558+
#region Merkle
1559+
1560+
/// <summary>
1561+
/// Gets the allowlist proof for a wallet address to claim from a drop contract.
1562+
/// This fetches the Merkle tree data from IPFS and calculates the proof locally.
1563+
/// </summary>
1564+
/// <param name="contract">The drop contract to interact with.</param>
1565+
/// <param name="walletAddress">The wallet address to get the proof for.</param>
1566+
/// <param name="claimConditionId">Optional claim condition ID. If not provided, uses the active condition.</param>
1567+
/// <param name="tokenId">Optional token ID for ERC1155 drops.</param>
1568+
/// <returns>The allowlist proof, or null if the wallet is not in the allowlist or it's a public mint.</returns>
1569+
/// <exception cref="ArgumentNullException">Thrown when the contract is null.</exception>
1570+
/// <exception cref="ArgumentException">Thrown when the wallet address is null or empty.</exception>
1571+
public static async Task<AllowlistProof> GetAllowlistProof(
1572+
this ThirdwebContract contract,
1573+
string walletAddress,
1574+
BigInteger? claimConditionId = null,
1575+
BigInteger? tokenId = null)
1576+
{
1577+
if (contract == null)
1578+
{
1579+
throw new ArgumentNullException(nameof(contract));
1580+
}
1581+
1582+
if (string.IsNullOrEmpty(walletAddress))
1583+
{
1584+
throw new ArgumentException("Wallet address must be provided", nameof(walletAddress));
1585+
}
1586+
1587+
try
1588+
{
1589+
// Get contract metadata
1590+
var contractUri = await ThirdwebContract.Read<string>(contract, "contractURI").ConfigureAwait(false);
1591+
var metadata = await ThirdwebStorage.Download<ContractMetadata>(contract.Client, contractUri).ConfigureAwait(false);
1592+
1593+
if (metadata?.Merkle == null || metadata.Merkle.Count == 0)
1594+
{
1595+
// No merkle data, return empty proof (public mint)
1596+
return new AllowlistProof
1597+
{
1598+
Proof = new List<byte[]>(),
1599+
QuantityLimitPerWallet = BigInteger.Zero,
1600+
PricePerToken = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935"), // MAX_UINT256
1601+
Currency = Constants.ADDRESS_ZERO
1602+
};
1603+
}
1604+
1605+
// Get claim condition
1606+
Drop_ClaimCondition claimCondition;
1607+
if (claimConditionId.HasValue)
1608+
{
1609+
if (tokenId.HasValue)
1610+
{
1611+
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", tokenId.Value, claimConditionId.Value).ConfigureAwait(false);
1612+
}
1613+
else
1614+
{
1615+
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", claimConditionId.Value).ConfigureAwait(false);
1616+
}
1617+
}
1618+
else
1619+
{
1620+
BigInteger activeId;
1621+
if (tokenId.HasValue)
1622+
{
1623+
activeId = await ThirdwebContract.Read<BigInteger>(contract, "getActiveClaimConditionId", tokenId.Value).ConfigureAwait(false);
1624+
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", tokenId.Value, activeId).ConfigureAwait(false);
1625+
}
1626+
else
1627+
{
1628+
activeId = await ThirdwebContract.Read<BigInteger>(contract, "getActiveClaimConditionId").ConfigureAwait(false);
1629+
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", activeId).ConfigureAwait(false);
1630+
}
1631+
}
1632+
1633+
// Check if it's a public mint (zero merkle root)
1634+
var merkleRootHex = claimCondition.MerkleRoot.BytesToHex();
1635+
if (merkleRootHex == "0x0000000000000000000000000000000000000000000000000000000000000000")
1636+
{
1637+
// Public mint, no proof needed
1638+
return new AllowlistProof
1639+
{
1640+
Proof = new List<byte[]>(),
1641+
QuantityLimitPerWallet = BigInteger.Zero,
1642+
PricePerToken = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935"),
1643+
Currency = Constants.ADDRESS_ZERO
1644+
};
1645+
}
1646+
1647+
// Find the tree info URI for this merkle root
1648+
if (!metadata.Merkle.TryGetValue(merkleRootHex, out var treeInfoUri))
1649+
{
1650+
// Try without 0x prefix or with different case
1651+
var found = false;
1652+
foreach (var kvp in metadata.Merkle)
1653+
{
1654+
if (kvp.Key.Equals(merkleRootHex, StringComparison.OrdinalIgnoreCase))
1655+
{
1656+
treeInfoUri = kvp.Value;
1657+
found = true;
1658+
break;
1659+
}
1660+
}
1661+
1662+
if (!found)
1663+
{
1664+
return null; // Merkle root not found in metadata
1665+
}
1666+
}
1667+
1668+
// Download tree info
1669+
var treeInfo = await ThirdwebStorage.Download<MerkleTreeInfo>(contract.Client, treeInfoUri).ConfigureAwait(false);
1670+
if (treeInfo?.BaseUri == null)
1671+
{
1672+
return null;
1673+
}
1674+
1675+
// Calculate shard key and download shard
1676+
var shardKey = MerkleTreeUtils.GetShardKey(walletAddress, treeInfo.ShardNybbles);
1677+
var shardUri = $"{treeInfo.BaseUri}/{shardKey}.json";
1678+
1679+
ShardData shardData;
1680+
try
1681+
{
1682+
shardData = await ThirdwebStorage.Download<ShardData>(contract.Client, shardUri).ConfigureAwait(false);
1683+
}
1684+
catch
1685+
{
1686+
// Try without .json extension
1687+
try
1688+
{
1689+
shardUri = $"{treeInfo.BaseUri}/{shardKey}";
1690+
shardData = await ThirdwebStorage.Download<ShardData>(contract.Client, shardUri).ConfigureAwait(false);
1691+
}
1692+
catch
1693+
{
1694+
return null; // Shard not found, wallet not in allowlist
1695+
}
1696+
}
1697+
1698+
// Calculate proof
1699+
return MerkleTreeUtils.CalculateMerkleProof(shardData, walletAddress);
1700+
}
1701+
catch
1702+
{
1703+
return null;
1704+
}
1705+
}
1706+
1707+
#endregion
1708+
15581709
#region DropERC20
15591710

15601711
/// <summary>
@@ -1601,8 +1752,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC20_Claim(this Thirdw
16011752

16021753
var payableAmount = isNativeToken ? rawAmountToClaim * activeClaimCondition.PricePerToken / BigInteger.Pow(10, 18) : BigInteger.Zero;
16031754

1604-
// TODO: Merkle
1605-
var allowlistProof = new object[] { Array.Empty<byte>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
1755+
// Get merkle proof for allowlist
1756+
var allowlistProofData = await contract.GetAllowlistProof(receiverAddress).ConfigureAwait(false);
1757+
object[] allowlistProof;
1758+
if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0)
1759+
{
1760+
allowlistProof = new object[]
1761+
{
1762+
allowlistProofData.Proof.ToArray(),
1763+
allowlistProofData.QuantityLimitPerWallet,
1764+
allowlistProofData.PricePerToken,
1765+
allowlistProofData.Currency
1766+
};
1767+
1768+
// Recalculate payable amount if allowlist has price override
1769+
var maxUint256 = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935");
1770+
if (allowlistProofData.PricePerToken < maxUint256)
1771+
{
1772+
var allowlistCurrency = allowlistProofData.Currency;
1773+
isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO;
1774+
payableAmount = isNativeToken ? rawAmountToClaim * allowlistProofData.PricePerToken / BigInteger.Pow(10, 18) : BigInteger.Zero;
1775+
}
1776+
}
1777+
else
1778+
{
1779+
allowlistProof = new object[] { Array.Empty<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
1780+
}
16061781

16071782
var fnArgs = new object[]
16081783
{
@@ -1739,8 +1914,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC721_Claim(this Third
17391914

17401915
var payableAmount = isNativeToken ? quantity * activeClaimCondition.PricePerToken : BigInteger.Zero;
17411916

1742-
// TODO: Merkle
1743-
var allowlistProof = new object[] { Array.Empty<byte>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
1917+
// Get merkle proof for allowlist
1918+
var allowlistProofData = await contract.GetAllowlistProof(receiverAddress).ConfigureAwait(false);
1919+
object[] allowlistProof;
1920+
if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0)
1921+
{
1922+
allowlistProof = new object[]
1923+
{
1924+
allowlistProofData.Proof.ToArray(),
1925+
allowlistProofData.QuantityLimitPerWallet,
1926+
allowlistProofData.PricePerToken,
1927+
allowlistProofData.Currency
1928+
};
1929+
1930+
// Recalculate payable amount if allowlist has price override
1931+
var maxUint256 = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935");
1932+
if (allowlistProofData.PricePerToken < maxUint256)
1933+
{
1934+
var allowlistCurrency = allowlistProofData.Currency;
1935+
isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO;
1936+
payableAmount = isNativeToken ? quantity * allowlistProofData.PricePerToken : BigInteger.Zero;
1937+
}
1938+
}
1939+
else
1940+
{
1941+
allowlistProof = new object[] { Array.Empty<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
1942+
}
17441943

17451944
var fnArgs = new object[]
17461945
{
@@ -1902,8 +2101,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC1155_Claim(this Thir
19022101

19032102
var payableAmount = isNativeToken ? quantity * activeClaimCondition.PricePerToken : BigInteger.Zero;
19042103

1905-
// TODO: Merkle
1906-
var allowlistProof = new object[] { Array.Empty<byte>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
2104+
// Get merkle proof for allowlist (passing tokenId for ERC1155)
2105+
var allowlistProofData = await contract.GetAllowlistProof(receiverAddress, null, tokenId).ConfigureAwait(false);
2106+
object[] allowlistProof;
2107+
if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0)
2108+
{
2109+
allowlistProof = new object[]
2110+
{
2111+
allowlistProofData.Proof.ToArray(),
2112+
allowlistProofData.QuantityLimitPerWallet,
2113+
allowlistProofData.PricePerToken,
2114+
allowlistProofData.Currency
2115+
};
2116+
2117+
// Recalculate payable amount if allowlist has price override
2118+
var maxUint256 = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935");
2119+
if (allowlistProofData.PricePerToken < maxUint256)
2120+
{
2121+
var allowlistCurrency = allowlistProofData.Currency;
2122+
isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO;
2123+
payableAmount = isNativeToken ? quantity * allowlistProofData.PricePerToken : BigInteger.Zero;
2124+
}
2125+
}
2126+
else
2127+
{
2128+
allowlistProof = new object[] { Array.Empty<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
2129+
}
19072130

19082131
var fnArgs = new object[]
19092132
{

0 commit comments

Comments
 (0)