diff --git a/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs b/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs index e0a34cb1..0c2e6e61 100644 --- a/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs +++ b/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs @@ -76,6 +76,132 @@ public class ContractMetadata /// [JsonProperty("image")] public string Image { get; set; } + + /// + /// Gets or sets the merkle tree mappings for claim conditions. + /// Key: Merkle root hash, Value: IPFS URI to tree info + /// + [JsonProperty("merkle")] + public Dictionary Merkle { get; set; } +} + +#endregion + +#region Merkle + +/// +/// Represents information about a sharded Merkle tree stored on IPFS. +/// +public class MerkleTreeInfo +{ + /// + /// Gets or sets the Merkle root hash. + /// + [JsonProperty("merkleRoot")] + public string MerkleRoot { get; set; } + + /// + /// Gets or sets the base IPFS URI for shard files. + /// + [JsonProperty("baseUri")] + public string BaseUri { get; set; } + + /// + /// Gets or sets the original entries IPFS URI. + /// + [JsonProperty("originalEntriesUri")] + public string OriginalEntriesUri { get; set; } + + /// + /// Gets or sets the number of hex characters used for shard keys. + /// + [JsonProperty("shardNybbles")] + public int ShardNybbles { get; set; } = 2; + + /// + /// Gets or sets the token decimals for price calculations. + /// + [JsonProperty("tokenDecimals")] + public int TokenDecimals { get; set; } = 0; +} + +/// +/// Represents a shard file containing whitelist entries and proofs. +/// +public class ShardData +{ + /// + /// Gets or sets the shard proofs (path from shard root to main root). + /// + [JsonProperty("proofs")] + public List Proofs { get; set; } + + /// + /// Gets or sets the whitelist entries in this shard. + /// + [JsonProperty("entries")] + public List Entries { get; set; } +} + +/// +/// Represents a whitelist entry for a claim condition. +/// +public class WhitelistEntry +{ + /// + /// Gets or sets the wallet address. + /// + [JsonProperty("address")] + public string Address { get; set; } + + /// + /// Gets or sets the maximum claimable amount ("unlimited" or a number). + /// + [JsonProperty("maxClaimable")] + public string MaxClaimable { get; set; } + + /// + /// Gets or sets the price override ("unlimited" or a number in wei). + /// + [JsonProperty("price")] + public string Price { get; set; } + + /// + /// Gets or sets the currency address for price override. + /// + [JsonProperty("currencyAddress")] + public string CurrencyAddress { get; set; } +} + +/// +/// Represents an allowlist proof for claiming tokens. +/// +[FunctionOutput] +public class AllowlistProof +{ + /// + /// Gets or sets the Merkle proof (array of bytes32 hashes). + /// + [Parameter("bytes32[]", "proof", 1)] + public List Proof { get; set; } + + /// + /// Gets or sets the maximum quantity this address can claim. + /// + [Parameter("uint256", "quantityLimitPerWallet", 2)] + public BigInteger QuantityLimitPerWallet { get; set; } + + /// + /// Gets or sets the price per token override. + /// + [Parameter("uint256", "pricePerToken", 3)] + public BigInteger PricePerToken { get; set; } + + /// + /// Gets or sets the currency address for the price override. + /// + [Parameter("address", "currency", 4)] + public string Currency { get; set; } } #endregion diff --git a/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs b/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs index 869344c0..75fdc5a6 100644 --- a/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs +++ b/Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs @@ -6,6 +6,8 @@ namespace Thirdweb; public static class ThirdwebExtensions { + private static readonly BigInteger MaxUint256 = BigInteger.Parse(Constants.MAX_UINT256_STR); + #region Common /// @@ -1555,6 +1557,161 @@ public static async Task> ERC1155_GetOwnedNFTs(this ThirdwebContract c #endregion + #region Merkle + + /// + /// Gets the allowlist proof for a wallet address to claim from a drop contract. + /// This fetches the Merkle tree data from IPFS and calculates the proof locally. + /// + /// The drop contract to interact with. + /// The wallet address to get the proof for. + /// Optional claim condition ID. If not provided, uses the active condition. + /// Optional token ID for ERC1155 drops. + /// + /// An with an empty proof array for public mints (no allowlist restriction), + /// or null if the wallet is not in the allowlist or the merkle root is not found in contract metadata. + /// + /// Thrown when the contract is null. + /// Thrown when the wallet address is null or empty. + public static async Task GetAllowlistProof( + this ThirdwebContract contract, + string walletAddress, + BigInteger? claimConditionId = null, + BigInteger? tokenId = null) + { + if (contract == null) + { + throw new ArgumentNullException(nameof(contract)); + } + + if (string.IsNullOrEmpty(walletAddress)) + { + throw new ArgumentException("Wallet address must be provided", nameof(walletAddress)); + } + + // Get contract metadata + var contractUri = await ThirdwebContract.Read(contract, "contractURI").ConfigureAwait(false); + var metadata = await ThirdwebStorage.Download(contract.Client, contractUri).ConfigureAwait(false); + + if (metadata?.Merkle == null || metadata.Merkle.Count == 0) + { + // No merkle data, return empty proof (public mint) + return new AllowlistProof + { + Proof = new List(), + QuantityLimitPerWallet = BigInteger.Zero, + PricePerToken = BigInteger.Parse(Constants.MAX_UINT256_STR), // MAX_UINT256 + Currency = Constants.ADDRESS_ZERO + }; + } + + // Get claim condition + Drop_ClaimCondition claimCondition; + if (claimConditionId.HasValue) + { + if (tokenId.HasValue) + { + claimCondition = await ThirdwebContract.Read(contract, "getClaimConditionById", tokenId.Value, claimConditionId.Value).ConfigureAwait(false); + } + else + { + claimCondition = await ThirdwebContract.Read(contract, "getClaimConditionById", claimConditionId.Value).ConfigureAwait(false); + } + } + else + { + BigInteger activeId; + if (tokenId.HasValue) + { + activeId = await ThirdwebContract.Read(contract, "getActiveClaimConditionId", tokenId.Value).ConfigureAwait(false); + claimCondition = await ThirdwebContract.Read(contract, "getClaimConditionById", tokenId.Value, activeId).ConfigureAwait(false); + } + else + { + activeId = await ThirdwebContract.Read(contract, "getActiveClaimConditionId").ConfigureAwait(false); + claimCondition = await ThirdwebContract.Read(contract, "getClaimConditionById", activeId).ConfigureAwait(false); + } + } + + // Check if it's a public mint (zero merkle root) + var merkleRootHex = claimCondition.MerkleRoot.BytesToHex(); + if (merkleRootHex == "0x0000000000000000000000000000000000000000000000000000000000000000") + { + // Public mint, no proof needed + return new AllowlistProof + { + Proof = new List(), + QuantityLimitPerWallet = BigInteger.Zero, + PricePerToken = BigInteger.Parse(Constants.MAX_UINT256_STR), + Currency = Constants.ADDRESS_ZERO + }; + } + + // Find the tree info URI for this merkle root + if (!metadata.Merkle.TryGetValue(merkleRootHex, out var treeInfoUri)) + { + // Try without 0x prefix or with different case + var found = false; + foreach (var kvp in metadata.Merkle) + { + if (kvp.Key.Equals(merkleRootHex, StringComparison.OrdinalIgnoreCase)) + { + treeInfoUri = kvp.Value; + found = true; + break; + } + } + + if (!found) + { + return null; // Merkle root not found in metadata + } + } + + // Download tree info + var treeInfo = await ThirdwebStorage.Download(contract.Client, treeInfoUri).ConfigureAwait(false); + if (treeInfo?.BaseUri == null) + { + return null; + } + + // Calculate shard key and download shard + var shardKey = MerkleTreeUtils.GetShardKey(walletAddress, treeInfo.ShardNybbles); + var shardUri = $"{treeInfo.BaseUri}/{shardKey}.json"; + + // Helper to check if exception indicates "not found" (expected when wallet not in allowlist) + static bool IsNotFoundError(Exception ex) => + ex.Message.Contains("NotFound", StringComparison.OrdinalIgnoreCase) || + ex.Message.Contains("404", StringComparison.Ordinal); + + ShardData shardData; + try + { + shardData = await ThirdwebStorage.Download(contract.Client, shardUri).ConfigureAwait(false); + } + catch (Exception ex) when (IsNotFoundError(ex)) + { + // Try without .json extension (some IPFS gateways don't need it) + try + { + shardUri = $"{treeInfo.BaseUri}/{shardKey}"; + shardData = await ThirdwebStorage.Download(contract.Client, shardUri).ConfigureAwait(false); + } + catch (Exception ex2) when (IsNotFoundError(ex2)) + { + // Shard not found - wallet is not in the allowlist (expected case) + return null; + } + // Other errors (network, auth, etc.) propagate up as unexpected + } + // Other errors from first attempt propagate up as unexpected + + // Calculate proof + return MerkleTreeUtils.CalculateMerkleProof(shardData, walletAddress); + } + + #endregion + #region DropERC20 /// @@ -1601,8 +1758,31 @@ public static async Task DropERC20_Claim(this Thirdw var payableAmount = isNativeToken ? rawAmountToClaim * activeClaimCondition.PricePerToken / BigInteger.Pow(10, 18) : BigInteger.Zero; - // TODO: Merkle - var allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + // Get merkle proof for allowlist + var allowlistProofData = await contract.GetAllowlistProof(receiverAddress).ConfigureAwait(false); + object[] allowlistProof; + if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0) + { + allowlistProof = new object[] + { + allowlistProofData.Proof.ToArray(), + allowlistProofData.QuantityLimitPerWallet, + allowlistProofData.PricePerToken, + allowlistProofData.Currency + }; + + // Recalculate payable amount if allowlist has price override + if (allowlistProofData.PricePerToken < MaxUint256) + { + var allowlistCurrency = allowlistProofData.Currency; + isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO; + payableAmount = isNativeToken ? rawAmountToClaim * allowlistProofData.PricePerToken / BigInteger.Pow(10, 18) : BigInteger.Zero; + } + } + else + { + allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + } var fnArgs = new object[] { @@ -1739,8 +1919,31 @@ public static async Task DropERC721_Claim(this Third var payableAmount = isNativeToken ? quantity * activeClaimCondition.PricePerToken : BigInteger.Zero; - // TODO: Merkle - var allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + // Get merkle proof for allowlist + var allowlistProofData = await contract.GetAllowlistProof(receiverAddress).ConfigureAwait(false); + object[] allowlistProof; + if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0) + { + allowlistProof = new object[] + { + allowlistProofData.Proof.ToArray(), + allowlistProofData.QuantityLimitPerWallet, + allowlistProofData.PricePerToken, + allowlistProofData.Currency + }; + + // Recalculate payable amount if allowlist has price override + if (allowlistProofData.PricePerToken < MaxUint256) + { + var allowlistCurrency = allowlistProofData.Currency; + isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO; + payableAmount = isNativeToken ? quantity * allowlistProofData.PricePerToken : BigInteger.Zero; + } + } + else + { + allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + } var fnArgs = new object[] { @@ -1902,8 +2105,31 @@ public static async Task DropERC1155_Claim(this Thir var payableAmount = isNativeToken ? quantity * activeClaimCondition.PricePerToken : BigInteger.Zero; - // TODO: Merkle - var allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + // Get merkle proof for allowlist (passing tokenId for ERC1155) + var allowlistProofData = await contract.GetAllowlistProof(receiverAddress, null, tokenId).ConfigureAwait(false); + object[] allowlistProof; + if (allowlistProofData != null && allowlistProofData.Proof != null && allowlistProofData.Proof.Count > 0) + { + allowlistProof = new object[] + { + allowlistProofData.Proof.ToArray(), + allowlistProofData.QuantityLimitPerWallet, + allowlistProofData.PricePerToken, + allowlistProofData.Currency + }; + + // Recalculate payable amount if allowlist has price override + if (allowlistProofData.PricePerToken < MaxUint256) + { + var allowlistCurrency = allowlistProofData.Currency; + isNativeToken = allowlistCurrency == Constants.NATIVE_TOKEN_ADDRESS || allowlistCurrency == Constants.ADDRESS_ZERO; + payableAmount = isNativeToken ? quantity * allowlistProofData.PricePerToken : BigInteger.Zero; + } + } + else + { + allowlistProof = new object[] { Array.Empty(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO }; + } var fnArgs = new object[] { diff --git a/Thirdweb/Thirdweb.Utils/Constants.cs b/Thirdweb/Thirdweb.Utils/Constants.cs index 08267613..9bee8ac6 100644 --- a/Thirdweb/Thirdweb.Utils/Constants.cs +++ b/Thirdweb/Thirdweb.Utils/Constants.cs @@ -13,6 +13,7 @@ public static class Constants public const string ADDRESS_ZERO = "0x0000000000000000000000000000000000000000"; public const string NATIVE_TOKEN_ADDRESS = "0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE"; public const double DECIMALS_18 = 1000000000000000000; + public const string MAX_UINT256_STR = "115792089237316195423570985008687907853269984665640564039457584007913129639935"; public const string IERC20_INTERFACE_ID = "0x36372b07"; public const string IERC721_INTERFACE_ID = "0x80ac58cd"; diff --git a/Thirdweb/Thirdweb.Utils/MerkleTree.cs b/Thirdweb/Thirdweb.Utils/MerkleTree.cs new file mode 100644 index 00000000..b4f35588 --- /dev/null +++ b/Thirdweb/Thirdweb.Utils/MerkleTree.cs @@ -0,0 +1,238 @@ +using System.Numerics; +using Nethereum.ABI; +using Nethereum.Util; + +namespace Thirdweb; + +/// +/// Provides utilities for Merkle tree operations compatible with OpenZeppelin and Thirdweb standards. +/// +public static class MerkleTreeUtils +{ + private static readonly BigInteger _maxUint256 = BigInteger.Parse("115792089237316195423570985008687907853269984665640564039457584007913129639935"); + + /// + /// Computes the Thirdweb-compatible leaf hash for a whitelist entry. + /// Format: keccak256(encodePacked(address, uint256 maxClaimable, uint256 price, address currency)) + /// + /// The whitelist entry to hash. + /// The 32-byte keccak256 hash of the encoded entry. + public static byte[] HashLeaf(WhitelistEntry entry) + { + var address = entry.Address.ToLower(); + + // maxClaimable: "unlimited" or missing = MAX_UINT256 + var maxClaimable = string.IsNullOrEmpty(entry.MaxClaimable) || string.Equals(entry.MaxClaimable, "unlimited", StringComparison.OrdinalIgnoreCase) + ? _maxUint256 + : BigInteger.Parse(entry.MaxClaimable); + + // price: "unlimited" or missing = MAX_UINT256 + var price = string.IsNullOrEmpty(entry.Price) || string.Equals(entry.Price, "unlimited", StringComparison.OrdinalIgnoreCase) + ? _maxUint256 + : BigInteger.Parse(entry.Price); + + // currency: missing = zero address + var currency = string.IsNullOrEmpty(entry.CurrencyAddress) + ? Constants.ADDRESS_ZERO + : entry.CurrencyAddress.ToLower(); + + // ABI encode packed + var abiEncode = new ABIEncode(); + var packed = abiEncode.GetABIEncodedPacked( + new ABIValue("address", address), + new ABIValue("uint256", maxClaimable), + new ABIValue("uint256", price), + new ABIValue("address", currency) + ); + + // keccak256 + return Sha3Keccack.Current.CalculateHash(packed); + } + + /// + /// Compares two byte arrays lexicographically (for OpenZeppelin-compatible sorting). + /// + private static int CompareBytes(byte[] a, byte[] b) + { + for (var i = 0; i < Math.Min(a.Length, b.Length); i++) + { + if (a[i] < b[i]) + { + return -1; + } + + if (a[i] > b[i]) + { + return 1; + } + } + + return a.Length.CompareTo(b.Length); + } + + /// + /// Hashes a pair of nodes in sorted order (OpenZeppelin standard). + /// + private static byte[] HashPair(byte[] a, byte[] b) + { + // Sort: smaller first + var (first, second) = CompareBytes(a, b) < 0 ? (a, b) : (b, a); + + // Concatenate and hash + var combined = new byte[first.Length + second.Length]; + Buffer.BlockCopy(first, 0, combined, 0, first.Length); + Buffer.BlockCopy(second, 0, combined, first.Length, second.Length); + + return Sha3Keccack.Current.CalculateHash(combined); + } + + /// + /// Calculates the full Merkle proof for a claimer address from shard data. + /// + /// The shard data containing entries and shard proofs. + /// The address to get the proof for. + /// The allowlist proof with full Merkle proof, or null if claimer not found. + public static AllowlistProof CalculateMerkleProof(ShardData shardData, string claimerAddress) + { + if (shardData?.Entries == null || shardData.Entries.Count == 0) + { + return null; + } + + var normalizedAddress = claimerAddress.ToLower(); + + // Find the claimer's entry + var claimerEntry = shardData.Entries.Find(e => string.Equals(e.Address, normalizedAddress, StringComparison.OrdinalIgnoreCase)); + if (claimerEntry == null) + { + return null; + } + + // Hash all entries + var leaves = new List(); + var leafToEntryMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var entry in shardData.Entries) + { + var leaf = HashLeaf(entry); + leaves.Add(leaf); + leafToEntryMap[leaf.BytesToHex()] = entry; + } + + // Sort leaves (OpenZeppelin standard) + leaves.Sort(CompareBytes); + + // Build tree layers + var layers = new List> { leaves }; + var currentLayer = leaves; + + while (currentLayer.Count > 1) + { + var nextLayer = new List(); + + for (var i = 0; i < currentLayer.Count; i += 2) + { + if (i + 1 == currentLayer.Count) + { + // Odd node, promote to next layer + nextLayer.Add(currentLayer[i]); + } + else + { + // Hash pair + nextLayer.Add(HashPair(currentLayer[i], currentLayer[i + 1])); + } + } + + layers.Add(nextLayer); + currentLayer = nextLayer; + } + + // Get claimer's leaf + var claimerLeaf = HashLeaf(claimerEntry); + var claimerLeafHex = claimerLeaf.BytesToHex(); + + // Find leaf index in sorted leaves + var leafIndex = -1; + for (var i = 0; i < leaves.Count; i++) + { + if (string.Equals(leaves[i].BytesToHex(), claimerLeafHex, StringComparison.OrdinalIgnoreCase)) + { + leafIndex = i; + break; + } + } + + if (leafIndex == -1) + { + return null; + } + + // Build mini-proof + var miniProof = new List(); + var index = leafIndex; + + for (var layerIdx = 0; layerIdx < layers.Count - 1; layerIdx++) + { + var layer = layers[layerIdx]; + var isRightNode = index % 2 == 1; + var pairIndex = isRightNode ? index - 1 : index + 1; + + if (pairIndex < layer.Count) + { + miniProof.Add(layer[pairIndex]); + } + + index /= 2; + } + + // Combine mini-proof with shard proofs + var fullProof = new List(miniProof); + + if (shardData.Proofs != null) + { + foreach (var proofHex in shardData.Proofs) + { + fullProof.Add(proofHex.HexToBytes()); + } + } + + // Parse entry values for AllowlistProof + var maxClaimable = string.IsNullOrEmpty(claimerEntry.MaxClaimable) || string.Equals(claimerEntry.MaxClaimable, "unlimited", StringComparison.OrdinalIgnoreCase) + ? _maxUint256 + : BigInteger.Parse(claimerEntry.MaxClaimable); + + var price = string.IsNullOrEmpty(claimerEntry.Price) || string.Equals(claimerEntry.Price, "unlimited", StringComparison.OrdinalIgnoreCase) + ? _maxUint256 + : BigInteger.Parse(claimerEntry.Price); + + var currency = string.IsNullOrEmpty(claimerEntry.CurrencyAddress) + ? Constants.ADDRESS_ZERO + : claimerEntry.CurrencyAddress; + + return new AllowlistProof + { + Proof = fullProof, + QuantityLimitPerWallet = maxClaimable, + PricePerToken = price, + Currency = currency + }; + } + + /// + /// Calculates the shard key for a wallet address. + /// + /// The wallet address. + /// The number of hex characters for the shard key (default: 2). + /// The shard key (e.g., "c1" for address 0xc143...). + public static string GetShardKey(string walletAddress, int shardNybbles = 2) + { + var normalized = walletAddress.ToLower(); + if (normalized.StartsWith("0x")) + { + normalized = normalized[2..]; + } + + return normalized[..Math.Min(shardNybbles, normalized.Length)]; + } +} diff --git a/global.json b/global.json index 45334b83..4c4c3ae5 100644 --- a/global.json +++ b/global.json @@ -1,7 +1,7 @@ { "sdk": { - "version": "8.0.401", - "rollForward": "latestPatch", + "version": "8.0.100", + "rollForward": "latestFeature", "allowPrerelease": false } }