Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.Types.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,132 @@ public class ContractMetadata
/// </summary>
[JsonProperty("image")]
public string Image { get; set; }

/// <summary>
/// Gets or sets the merkle tree mappings for claim conditions.
/// Key: Merkle root hash, Value: IPFS URI to tree info
/// </summary>
[JsonProperty("merkle")]
public Dictionary<string, string> Merkle { get; set; }
}

#endregion

#region Merkle

/// <summary>
/// Represents information about a sharded Merkle tree stored on IPFS.
/// </summary>
public class MerkleTreeInfo
{
/// <summary>
/// Gets or sets the Merkle root hash.
/// </summary>
[JsonProperty("merkleRoot")]
public string MerkleRoot { get; set; }

/// <summary>
/// Gets or sets the base IPFS URI for shard files.
/// </summary>
[JsonProperty("baseUri")]
public string BaseUri { get; set; }

/// <summary>
/// Gets or sets the original entries IPFS URI.
/// </summary>
[JsonProperty("originalEntriesUri")]
public string OriginalEntriesUri { get; set; }

/// <summary>
/// Gets or sets the number of hex characters used for shard keys.
/// </summary>
[JsonProperty("shardNybbles")]
public int ShardNybbles { get; set; } = 2;

/// <summary>
/// Gets or sets the token decimals for price calculations.
/// </summary>
[JsonProperty("tokenDecimals")]
public int TokenDecimals { get; set; } = 0;
}

/// <summary>
/// Represents a shard file containing whitelist entries and proofs.
/// </summary>
public class ShardData
{
/// <summary>
/// Gets or sets the shard proofs (path from shard root to main root).
/// </summary>
[JsonProperty("proofs")]
public List<string> Proofs { get; set; }

/// <summary>
/// Gets or sets the whitelist entries in this shard.
/// </summary>
[JsonProperty("entries")]
public List<WhitelistEntry> Entries { get; set; }
}

/// <summary>
/// Represents a whitelist entry for a claim condition.
/// </summary>
public class WhitelistEntry
{
/// <summary>
/// Gets or sets the wallet address.
/// </summary>
[JsonProperty("address")]
public string Address { get; set; }

/// <summary>
/// Gets or sets the maximum claimable amount ("unlimited" or a number).
/// </summary>
[JsonProperty("maxClaimable")]
public string MaxClaimable { get; set; }

/// <summary>
/// Gets or sets the price override ("unlimited" or a number in wei).
/// </summary>
[JsonProperty("price")]
public string Price { get; set; }

/// <summary>
/// Gets or sets the currency address for price override.
/// </summary>
[JsonProperty("currencyAddress")]
public string CurrencyAddress { get; set; }
}

/// <summary>
/// Represents an allowlist proof for claiming tokens.
/// </summary>
[FunctionOutput]
public class AllowlistProof
{
/// <summary>
/// Gets or sets the Merkle proof (array of bytes32 hashes).
/// </summary>
[Parameter("bytes32[]", "proof", 1)]
public List<byte[]> Proof { get; set; }

/// <summary>
/// Gets or sets the maximum quantity this address can claim.
/// </summary>
[Parameter("uint256", "quantityLimitPerWallet", 2)]
public BigInteger QuantityLimitPerWallet { get; set; }

/// <summary>
/// Gets or sets the price per token override.
/// </summary>
[Parameter("uint256", "pricePerToken", 3)]
public BigInteger PricePerToken { get; set; }

/// <summary>
/// Gets or sets the currency address for the price override.
/// </summary>
[Parameter("address", "currency", 4)]
public string Currency { get; set; }
}

#endregion
Expand Down
236 changes: 230 additions & 6 deletions Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1555,6 +1555,158 @@ public static async Task<List<NFT>> ERC1155_GetOwnedNFTs(this ThirdwebContract c

#endregion

#region Merkle

/// <summary>
/// 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.
/// </summary>
/// <param name="contract">The drop contract to interact with.</param>
/// <param name="walletAddress">The wallet address to get the proof for.</param>
/// <param name="claimConditionId">Optional claim condition ID. If not provided, uses the active condition.</param>
/// <param name="tokenId">Optional token ID for ERC1155 drops.</param>
/// <returns>The allowlist proof, or null if the wallet is not in the allowlist or it's a public mint.</returns>
/// <exception cref="ArgumentNullException">Thrown when the contract is null.</exception>
/// <exception cref="ArgumentException">Thrown when the wallet address is null or empty.</exception>
public static async Task<AllowlistProof> 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));
}

try
{
// Get contract metadata
var contractUri = await ThirdwebContract.Read<string>(contract, "contractURI").ConfigureAwait(false);
var metadata = await ThirdwebStorage.Download<ContractMetadata>(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<byte[]>(),
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<Drop_ClaimCondition>(contract, "getClaimConditionById", tokenId.Value, claimConditionId.Value).ConfigureAwait(false);
}
else
{
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", claimConditionId.Value).ConfigureAwait(false);
}
}
else
{
BigInteger activeId;
if (tokenId.HasValue)
{
activeId = await ThirdwebContract.Read<BigInteger>(contract, "getActiveClaimConditionId", tokenId.Value).ConfigureAwait(false);
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(contract, "getClaimConditionById", tokenId.Value, activeId).ConfigureAwait(false);
}
else
{
activeId = await ThirdwebContract.Read<BigInteger>(contract, "getActiveClaimConditionId").ConfigureAwait(false);
claimCondition = await ThirdwebContract.Read<Drop_ClaimCondition>(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<byte[]>(),
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<MerkleTreeInfo>(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";

ShardData shardData;
try
{
shardData = await ThirdwebStorage.Download<ShardData>(contract.Client, shardUri).ConfigureAwait(false);
}
catch
{
// Try without .json extension
try
{
shardUri = $"{treeInfo.BaseUri}/{shardKey}";
shardData = await ThirdwebStorage.Download<ShardData>(contract.Client, shardUri).ConfigureAwait(false);
}
catch
{
return null; // Shard not found, wallet not in allowlist
}
}

// Calculate proof
return MerkleTreeUtils.CalculateMerkleProof(shardData, walletAddress);
}
catch (Exception)
{
// TODO: Log exception for debugging without crashing
return null;
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash

# Search for ThirdwebExtensions.cs file
find . -name "ThirdwebExtensions.cs" -type f 2>/dev/null

Repository: thirdweb-dev/dotnet

Length of output: 115


🏁 Script executed:

#!/bin/bash

# Read lines 1698-1710 to see the full context of the exception handling
sed -n '1698,1710p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 368


🏁 Script executed:

#!/bin/bash

# Get broader context - lines 1680-1710 to see the method signature and full try-catch
sed -n '1680,1710p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 1006


🏁 Script executed:

#!/bin/bash

# Get the full method including signature - lines 1660-1710
sed -n '1660,1710p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 1684


🏁 Script executed:

#!/bin/bash

# Get the method signature - lines 1630-1665
sed -n '1630,1665p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 1464


🏁 Script executed:

#!/bin/bash

# Get the method signature and start - lines 1600-1635
sed -n '1600,1635p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 1937


🏁 Script executed:

#!/bin/bash

# Get the method signature - lines 1550-1605
sed -n '1550,1605p' ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs

Repository: thirdweb-dev/dotnet

Length of output: 2448


🏁 Script executed:

#!/bin/bash

# Check if there's a logging infrastructure in the codebase
# Look for common logging patterns
rg -l "ILogger|logger|log|Debug|Trace|Error" --type cs | head -20

Repository: thirdweb-dev/dotnet

Length of output: 1172


🏁 Script executed:

#!/bin/bash

# Check what logging patterns are used in ThirdwebExtensions.cs
rg "Debug|log|Log|error|Error" ./Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs | head -20

Repository: thirdweb-dev/dotnet

Length of output: 1518


🏁 Script executed:

#!/bin/bash

# Check how logging is actually done in the codebase - look for actual log calls
rg "System.Diagnostics.Debug|Console.WriteLine|logger\." --type cs -A 2 | head -40

Repository: thirdweb-dev/dotnet

Length of output: 2342


🏁 Script executed:

#!/bin/bash

# Check ThirdwebContract and ThirdwebClient for logging setup
rg "Log|log|Logger|ILogger" ./Thirdweb/Thirdweb.Contracts/ThirdwebContract.cs 2>/dev/null | head -10
rg "Log|log|Logger|ILogger" ./Thirdweb/Thirdweb.Client/ThirdwebClient.cs 2>/dev/null | head -10

Repository: thirdweb-dev/dotnet

Length of output: 45


Broad exception handling lacks production observability.

The catch-all exception handler silently returns null without logging, making it impossible to debug failures in production. While the method documents returning null for expected cases (missing allowlist or public mint), unexpected errors during Merkle tree calculation or shard download are also silently swallowed.

Note: The suggested System.Diagnostics.Debug.WriteLine() only works in Debug builds and won't provide production observability. Consider implementing a structured logging approach (if available in ThirdwebClient) or distinguish between expected null returns and actual errors by allowing specific exceptions to propagate.

🤖 Prompt for AI Agents
In Thirdweb/Thirdweb.Extensions/ThirdwebExtensions.cs around lines 1701 to 1705,
the broad catch(Exception) silently returns null which hides production errors;
replace this with targeted handling: catch only the expected exceptions that
mean “no allowlist or no public mint” and return null for those, and otherwise
log the caught exception using the project's structured logging (use
ThirdwebClient's logger if available, or inject/obtain an ILogger) and rethrow
or wrap the exception; ensure you do not use Debug.WriteLine so that logs appear
in production, and include exception message and stack trace in the log entry
for diagnostics.

}

#endregion

#region DropERC20

/// <summary>
Expand Down Expand Up @@ -1601,8 +1753,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC20_Claim(this Thirdw

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

// TODO: Merkle
var allowlistProof = new object[] { Array.Empty<byte>(), 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
var maxUint256 = BigInteger.Parse(Constants.MAX_UINT256_STR);
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<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
}

var fnArgs = new object[]
{
Expand Down Expand Up @@ -1739,8 +1915,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC721_Claim(this Third

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

// TODO: Merkle
var allowlistProof = new object[] { Array.Empty<byte>(), 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
var maxUint256 = BigInteger.Parse(Constants.MAX_UINT256_STR);
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<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
}

var fnArgs = new object[]
{
Expand Down Expand Up @@ -1902,8 +2102,32 @@ public static async Task<ThirdwebTransactionReceipt> DropERC1155_Claim(this Thir

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

// TODO: Merkle
var allowlistProof = new object[] { Array.Empty<byte>(), 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
var maxUint256 = BigInteger.Parse(Constants.MAX_UINT256_STR);
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<byte[]>(), BigInteger.Zero, BigInteger.Zero, Constants.ADDRESS_ZERO };
}

var fnArgs = new object[]
{
Expand Down
Loading