Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
11 changes: 11 additions & 0 deletions BiatecTokensApi/Controllers/TokenController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ public TokenController(
/// If a request with the same key is received within 24 hours, the cached response will be returned.
/// This prevents accidental duplicate deployments.
/// </remarks>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("erc20-mintable/create")]
[ProducesResponseType(typeof(EVMTokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -153,6 +154,7 @@ public async Task<IActionResult> ERC20MintableTokenCreate([FromBody] ERC20Mintab
/// response with an <see cref="EVMTokenDeploymentResponse"/> if the deployment is successful. Returns a 400 Bad
/// Request response if the request model is invalid. Returns a 500 Internal Server Error response if an error
/// occurs during deployment.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("erc20-preminted/create")]
[ProducesResponseType(typeof(EVMTokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -212,6 +214,7 @@ public async Task<IActionResult> ERC20PremnitedTokenCreate([FromBody] ERC20Premi
/// <see cref="ARC3TokenDeploymentResponse"/> if the token is created successfully. Returns a 400 Bad Request
/// response if the request model is invalid. Returns a 500 Internal Server Error response if an error occurs
/// during token creation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("asa-ft/create")]
[ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -269,6 +272,7 @@ public async Task<IActionResult> CreateASAToken([FromBody] ASAFungibleTokenDeplo
/// <see cref="ASATokenDeploymentResponse"/> if the token is created successfully. Returns a 400 Bad Request
/// response if the request is invalid. Returns a 500 Internal Server Error response if an unexpected error
/// occurs during the operation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("asa-nft/create")]
[ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -328,6 +332,7 @@ public async Task<IActionResult> CreateASANFT([FromBody] ASANonFungibleTokenDepl
/// <see cref="ASATokenDeploymentResponse"/> if the token is created successfully. Returns a 400 Bad Request
/// response if the request is invalid. Returns a 500 Internal Server Error response if an unexpected error
/// occurs during the operation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("asa-fnft/create")]
[ProducesResponseType(typeof(ASATokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -384,6 +389,7 @@ public async Task<IActionResult> CreateASAFNFT([FromBody] ASAFractionalNonFungib
/// </summary>
/// <param name="request">ARC3 token creation parameters including metadata</param>
/// <returns>Creation result with asset ID and transaction details</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc3-ft/create")]
[ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -443,6 +449,7 @@ public async Task<IActionResult> CreateARC3FungibleToken([FromBody] ARC3Fungible
/// created successfully.</description></item> <item><description>A 400 Bad Request response if the request
/// model is invalid.</description></item> <item><description>A 500 Internal Server Error response if an
/// unexpected error occurs during token creation.</description></item> </list></returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc3-nft/create")]
[ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -501,6 +508,7 @@ public async Task<IActionResult> CreateARC3NFT([FromBody] ARC3NonFungibleTokenDe
/// <returns>An <see cref="IActionResult"/> containing the result of the operation: - A 200 OK response with an <see
/// cref="ARC3TokenDeploymentResponse"/> if the token is successfully created. - A 400 Bad Request response if
/// the model state is invalid. - A 500 Internal Server Error response if an error occurs during token creation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc3-fnft/create")]
[ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -558,6 +566,7 @@ public async Task<IActionResult> CreateARC3FractionalNFT([FromBody] ARC3Fraction
/// with an <see cref="ARC200TokenDeploymentResponse"/> if the token is successfully created. - A 400 Bad Request
/// response if the request is invalid. - A 500 Internal Server Error response if an unexpected error occurs
/// during the operation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc200-mintable/create")]
[ProducesResponseType(typeof(ARC200TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -615,6 +624,7 @@ public async Task<IActionResult> ARC200MintableTokenDeploymentRequest([FromBody]
/// response with an <see cref="ARC3TokenDeploymentResponse"/> if the token is created successfully, a 400 Bad
/// Request response if the request is invalid, or a 500 Internal Server Error response if an unexpected error
/// occurs.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc200-preminted/create")]
[ProducesResponseType(typeof(ARC3TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down Expand Up @@ -672,6 +682,7 @@ public async Task<IActionResult> CreateARC200Preminted([FromBody] ARC200Preminte
/// with an <see cref="ARC200TokenDeploymentResponse"/> if the token is successfully created. - A 400 Bad Request
/// response if the request is invalid. - A 500 Internal Server Error response if an unexpected error occurs
/// during the operation.</returns>
[TokenDeploymentSubscription]
[IdempotencyKey]
[HttpPost("arc1400-mintable/create")]
[ProducesResponseType(typeof(ARC200TokenDeploymentResponse), StatusCodes.Status200OK)]
Expand Down
80 changes: 77 additions & 3 deletions BiatecTokensApi/Filters/IdempotencyAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Mvc.Filters;
using System.Collections.Concurrent;
using System.Text.Json;
using System.Security.Cryptography;
using System.Text;

namespace BiatecTokensApi.Filters
{
Expand All @@ -12,6 +14,10 @@ namespace BiatecTokensApi.Filters
/// This filter ensures that duplicate requests with the same idempotency key return the same response.
/// It stores the response in memory for a configurable duration (default 24 hours).
///
/// **Security:** The filter validates that cached requests match current request parameters.
/// If the same idempotency key is used with different parameters, a warning is logged and
/// the request is rejected to prevent bypassing business logic.
///
/// Usage:
/// [IdempotencyKey]
/// [HttpPost("create")]
Expand Down Expand Up @@ -49,28 +55,57 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context
}

var key = idempotencyKey.ToString();
var logger = context.HttpContext.RequestServices.GetService<ILogger<IdempotencyKeyAttribute>>();

// Clean up expired entries periodically
CleanupExpiredEntries();

// Compute hash of request parameters for validation
var requestHash = ComputeRequestHash(context, context.ActionArguments);

// Check if we've seen this key before
if (_cache.TryGetValue(key, out var record))
{
// Check if the record has expired
if (DateTime.UtcNow - record.Timestamp < Expiration)
{
// Validate that the request parameters match
if (record.RequestHash != requestHash)
{
logger?.LogWarning(
"Idempotency key reused with different parameters. Key: {Key}, CorrelationId: {CorrelationId}",
key, context.HttpContext.TraceIdentifier);

context.Result = new BadRequestObjectResult(new
{
success = false,
errorCode = "IDEMPOTENCY_KEY_MISMATCH",
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The error code "IDEMPOTENCY_KEY_MISMATCH" is hardcoded as a string literal instead of being defined as a constant in the ErrorCodes class. All other error codes in the codebase are defined in BiatecTokensApi/Models/ErrorCodes.cs (e.g., ErrorCodes.SUBSCRIPTION_LIMIT_REACHED, ErrorCodes.UNAUTHORIZED). This hardcoded string breaks the established pattern and makes the code harder to maintain and test. Add IDEMPOTENCY_KEY_MISMATCH as a constant in the ErrorCodes class and reference it using ErrorCodes.IDEMPOTENCY_KEY_MISMATCH.

Copilot uses AI. Check for mistakes.
errorMessage = "The provided idempotency key has been used with different request parameters. Please use a unique key for this request or reuse the same parameters.",
correlationId = context.HttpContext.TraceIdentifier
});
Comment on lines +79 to +85
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The error response uses an anonymous object instead of the ApiErrorResponse class, which is inconsistent with the rest of the codebase. The TokenDeploymentSubscriptionAttribute (in the same PR) and other filters like SubscriptionTierValidationAttribute use ApiErrorResponse for structured error responses. This inconsistency makes it harder to handle errors uniformly on the client side and breaks the established pattern for error handling. Consider replacing the anonymous object with an ApiErrorResponse instance that includes all standard fields like Success, ErrorCode, ErrorMessage, RemediationHint, Timestamp, CorrelationId, and Path.

Copilot uses AI. Check for mistakes.
return;
}

// Return cached response
context.Result = new ObjectResult(record.Response)
{
StatusCode = record.StatusCode
};
context.HttpContext.Response.Headers.Add("X-Idempotency-Hit", "true");
context.HttpContext.Response.Headers["X-Idempotency-Hit"] = "true";

logger?.LogDebug(
"Idempotency cache hit. Key: {Key}, CorrelationId: {CorrelationId}",
key, context.HttpContext.TraceIdentifier);

return;
}
else
{
// Expired - remove it and continue
_cache.TryRemove(key, out _);
logger?.LogDebug(
"Idempotency record expired and removed. Key: {Key}",
key);
}
}

Expand All @@ -85,11 +120,49 @@ public override async Task OnActionExecutionAsync(ActionExecutingContext context
Key = key,
Response = objectResult.Value,
StatusCode = objectResult.StatusCode ?? 200,
Timestamp = DateTime.UtcNow
Timestamp = DateTime.UtcNow,
RequestHash = requestHash
};

_cache.TryAdd(key, newRecord);
executedContext.HttpContext.Response.Headers.Add("X-Idempotency-Hit", "false");
executedContext.HttpContext.Response.Headers["X-Idempotency-Hit"] = "false";

logger?.LogDebug(
"Idempotency record cached. Key: {Key}, StatusCode: {StatusCode}",
key, newRecord.StatusCode);
}
}

/// <summary>
/// Computes a hash of the request parameters for validation
/// </summary>
/// <param name="context">Action executing context</param>
/// <param name="arguments">Action arguments</param>
/// <returns>Hash string</returns>
private string ComputeRequestHash(ActionExecutingContext context, IDictionary<string, object?> arguments)
{
try
{
// Serialize arguments to JSON for consistent hashing
var json = JsonSerializer.Serialize(arguments, new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.Never
});

// Compute SHA256 hash
using var sha256 = SHA256.Create();
var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(json));
return Convert.ToBase64String(hashBytes);
}
catch (Exception ex)
{
// Log serialization failure and return empty hash
var logger = context.HttpContext?.RequestServices?.GetService<ILogger<IdempotencyKeyAttribute>>();
logger?.LogWarning(ex, "Failed to compute request hash for idempotency check. Returning empty hash.");

// Return empty hash (will not match cached requests, treating as new request)
return string.Empty;
}
}

Expand Down Expand Up @@ -122,6 +195,7 @@ private class IdempotencyRecord
public object? Response { get; set; }
public int StatusCode { get; set; }
public DateTime Timestamp { get; set; }
public string RequestHash { get; set; } = string.Empty;
}
}
}
124 changes: 124 additions & 0 deletions BiatecTokensApi/Filters/TokenDeploymentSubscriptionAttribute.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using BiatecTokensApi.Helpers;
using BiatecTokensApi.Models;
using BiatecTokensApi.Services.Interface;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;

namespace BiatecTokensApi.Filters
{
/// <summary>
/// Action filter that validates subscription tier entitlements for token deployment operations
/// </summary>
/// <remarks>
/// This filter enforces subscription-based access control by validating that users have
/// appropriate tier permissions and available deployment quota before proceeding with token deployment.
/// When validation fails, it returns a clear error response that prompts users to upgrade their subscription.
///
/// **Enforcement Policy:**
/// - Free tier: Limited token deployments (3)
/// - Basic tier: Moderate token deployments (10)
/// - Premium tier: Generous token deployments (50)
/// - Enterprise tier: Unlimited token deployments
///
/// **Usage:**
/// Apply this attribute to token deployment endpoints:
/// [TokenDeploymentSubscription]
/// [HttpPost("create")]
/// public async Task&lt;IActionResult&gt; CreateToken(...) { ... }
/// </remarks>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public class TokenDeploymentSubscriptionAttribute : ActionFilterAttribute
{
/// <summary>
/// Called before the action executes to validate subscription tier and deployment quota
/// </summary>
/// <param name="context">The action executing context</param>
public override async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
{
// Get required services from DI
var tierService = context.HttpContext.RequestServices.GetService<ISubscriptionTierService>();
var logger = context.HttpContext.RequestServices.GetService<ILogger<TokenDeploymentSubscriptionAttribute>>();

if (tierService == null || logger == null)
{
logger?.LogWarning("TokenDeploymentSubscriptionAttribute: Required services not available");
await next();
return;
}
Comment on lines +42 to +47
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

When required services (tierService or logger) are not available, the filter proceeds with the request without validation. This follows the same pattern as SubscriptionTierValidationAttribute (line 40-45 in SubscriptionTierValidationAttribute.cs), but represents a fail-open security posture that could allow unauthorized deployments if dependency injection is misconfigured. Consider changing this to fail-closed by returning an error response (500 Internal Server Error) instead of proceeding, which would prevent deployments when the subscription service is unavailable and make misconfigurations more obvious.

Copilot uses AI. Check for mistakes.

// Get user address from claims
var userAddress = context.HttpContext.User.Identity?.Name;
if (string.IsNullOrEmpty(userAddress))
{
logger.LogWarning("TokenDeploymentSubscriptionAttribute: No user identity found");
context.Result = new UnauthorizedObjectResult(new ApiErrorResponse
{
Success = false,
ErrorCode = ErrorCodes.UNAUTHORIZED,
ErrorMessage = "Authentication required",
RemediationHint = "Provide a valid ARC-0014 authentication token in the Authorization header.",
Timestamp = DateTime.UtcNow,
CorrelationId = context.HttpContext.TraceIdentifier,
Path = LoggingHelper.SanitizeLogInput(context.HttpContext.Request.Path)
});
return;
}

// Sanitize user address for logging
var sanitizedUserAddress = LoggingHelper.SanitizeLogInput(userAddress);

// Check if user can deploy tokens
var canDeploy = await tierService.CanDeployTokenAsync(userAddress);

if (!canDeploy)
{
// Get tier details for error message
var currentTier = await tierService.GetUserTierAsync(userAddress);
var tierLimits = tierService.GetTierLimits(currentTier);
var currentCount = await tierService.GetTokenDeploymentCountAsync(userAddress);

logger.LogWarning(
"Token deployment denied for user {UserAddress}: Deployment limit reached. Current tier: {Tier}, Current count: {Count}, Max: {Max}. CorrelationId: {CorrelationId}",
sanitizedUserAddress, currentTier, currentCount, tierLimits.MaxTokenDeployments, context.HttpContext.TraceIdentifier);

context.Result = new ObjectResult(new ApiErrorResponse
{
Success = false,
ErrorCode = ErrorCodes.SUBSCRIPTION_LIMIT_REACHED,
ErrorMessage = $"Token deployment limit reached for {tierLimits.TierName} tier. You have deployed {currentCount} of {tierLimits.MaxTokenDeployments} allowed tokens.",
RemediationHint = $"Upgrade to a higher tier to deploy more tokens. Current tier: {tierLimits.TierName}. Visit the billing page to upgrade your subscription.",
Details = new Dictionary<string, object>
{
{ "currentTier", currentTier.ToString() },
{ "currentDeployments", currentCount },
{ "maxDeployments", tierLimits.MaxTokenDeployments },
{ "tierDescription", tierLimits.Description }
},
Timestamp = DateTime.UtcNow,
CorrelationId = context.HttpContext.TraceIdentifier,
Path = LoggingHelper.SanitizeLogInput(context.HttpContext.Request.Path)
})
{
StatusCode = StatusCodes.Status402PaymentRequired
};
return;
}

// Execute the action
var executedContext = await next();

// If the action was successful (2xx status code), record the deployment
var statusCode = executedContext.HttpContext.Response.StatusCode;
if (statusCode >= 200 && statusCode < 300)
{
// Record the deployment
await tierService.RecordTokenDeploymentAsync(userAddress);

var newCount = await tierService.GetTokenDeploymentCountAsync(userAddress);
logger.LogInformation(
"Token deployment recorded for user {UserAddress}. New deployment count: {Count}. CorrelationId: {CorrelationId}",
sanitizedUserAddress, newCount, context.HttpContext.TraceIdentifier);
}
}
}
}
Loading
Loading