IdempotentAttribute for WabiSabi coordinator actions
MODIFY
WalletWasabi.Backend/Controllers/WabiSabiController.cs
WalletWasabi.Backend/Controllers/WabiSabiController.cs
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
+using WalletWasabi.Backend.Filters;
using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Models;
@@ -11,31 +12,36 @@ namespace WalletWasabi.Backend.Controllers
[Route("[controller]")]
public class WabiSabiController : ControllerBase
{
- private IArenaRequestHandler handler;
+ private readonly IArenaRequestHandler handler;
public WabiSabiController(IArenaRequestHandler handler)
{
this.handler = handler;
}
+ [HttpPost("connection-confirmation")]
+ [IdempotentActionFilterAttribute]
public Task<ConnectionConfirmationResponse> ConfirmConnectionAsync(ConnectionConfirmationRequest request, CancellationToken cancellableToken)
{
return handler.ConfirmConnectionAsync(request, cancellableToken);
}
[HttpPost("input-registration")]
+ [IdempotentActionFilterAttribute]
public Task<InputRegistrationResponse> RegisterInputAsync(InputRegistrationRequest request, CancellationToken cancellableToken)
{
return handler.RegisterInputAsync(request, cancellableToken);
}
[HttpPost("output-registration")]
+ [IdempotentActionFilterAttribute]
public Task<OutputRegistrationResponse> RegisterOutputAsync(OutputRegistrationRequest request, CancellationToken cancellableToken)
{
return handler.RegisterOutputAsync(request, cancellableToken);
}
[HttpPost("credential-issuance")]
+ [IdempotentActionFilterAttribute]
public Task<ReissueCredentialResponse> ReissueCredentialAsync(ReissueCredentialRequest request, CancellationToken cancellableToken)
{
return handler.ReissueCredentialAsync(request, cancellableToken);

ADD
WalletWasabi.Backend/Filters/IdempotentActionFilterAttribute.cs
+using Microsoft.AspNetCore.Mvc;
+
+namespace WalletWasabi.Backend.Filters
+{
+ public class IdempotentActionFilterAttribute : TypeFilterAttribute
+ {
+ public IdempotentActionFilterAttribute()
+ : base(typeof(IdempotentActionFilterAttributeImpl))
+ {
+ }
+ }
+}

ADD
WalletWasabi.Backend/Filters/IdempotentActionFilterAttributeImpl.cs
+
+using System;
+using System.Security.Cryptography;
+using System.Text;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.Extensions.Caching.Memory;
+
+namespace WalletWasabi.Backend.Filters
+{
+ public class IdempotentActionFilterAttributeImpl : ActionFilterAttribute
+ {
+ private readonly IMemoryCache _cache;
+
+ public IdempotentActionFilterAttributeImpl(IMemoryCache cache)
+ : base()
+ {
+ _cache = cache;
+ }
+
+ public override void OnActionExecuting(ActionExecutingContext context)
+ {
+ var request = context.HttpContext.Request;
+
+ var (cacheKey, body) = GetCacheEntryKey(request);
+
+ if (_cache.TryGetValue<OkObjectResult>(cacheKey, out var cachedResponse))
+ {
+ context.Result = cachedResponse;
+ return;
+ }
+
+ context.HttpContext.Items["cached-key"] = cacheKey;
+ }
+
+ public override void OnResultExecuted(ResultExecutedContext context)
+ {
+ var cacheKey = context.HttpContext.Items["cached-key"];
+
+ _cache.Set(cacheKey, context.Result, DateTimeOffset.UtcNow.AddMinutes(5));
+ }
+
+ private (string, string) GetCacheEntryKey(HttpRequest request)
+ {
+ using var reader = new System.IO.StreamReader(request.Body);
+ var body = reader.ReadToEnd();
+
+ var rawKey = string.Join(":", request.Path, body);
+ using var sha256Hash = SHA256.Create();
+ var bytes = sha256Hash.ComputeHash(Encoding.UTF8.GetBytes(rawKey));
+
+ return ("arena-request-cache-key: " + ByteHelpers.ToHex(bytes), body);
+ }
+ }
+}

ADD
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/Filters/IdempotencyActionFilterTests.cs
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.Abstractions;
+using Microsoft.AspNetCore.Mvc.Filters;
+using Microsoft.AspNetCore.Mvc.ModelBinding;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.Caching.Memory;
+using Moq;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Threading.Tasks;
+using WalletWasabi.Backend.Filters;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend
+{
+ public class IdempotencyActionFilterTests
+ {
+ [Fact]
+ public async Task ShouldCacheResponse()
+ {
+ var actionContext = CreateActionExecutingContext();
+ var cache = new MemoryCache(new MemoryCacheOptions());
+ var filter = new IdempotentActionFilterAttributeImpl(cache);
+
+ filter.OnActionExecuting(actionContext);
+
+ var resultContext = CreateResulExecutedContext(actionContext);
+ filter.OnResultExecuted(resultContext);
+
+ actionContext = CreateActionExecutingContext();
+ filter.OnActionExecuting(actionContext);
+
+ Assert.Equal(1, cache.Count);
+
+ var returned = Assert.IsType<OkObjectResult>(resultContext.Result);
+ var objValue = Assert.IsType<KeyValuePair<string, string>>(returned.Value);
+ Assert.Equal("Greeting", objValue.Key);
+ Assert.Equal("Hello world", objValue.Value);
+ }
+
+ private static ActionExecutingContext CreateActionExecutingContext()
+ {
+ var modelState = new ModelStateDictionary();
+ var httpContext = new DefaultHttpContext();
+ httpContext.Request.Path = "/api";
+ httpContext.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("Hello world"));
+ var actionContext = new ActionExecutingContext(
+ new ActionContext(
+ httpContext: httpContext,
+ routeData: new RouteData(),
+ actionDescriptor: new ActionDescriptor(),
+ modelState: modelState
+ ),
+ new List<IFilterMetadata>(),
+ new Dictionary<string, object>(),
+ new Mock<Controller>().Object);
+
+ return actionContext;
+ }
+
+ private static ResultExecutedContext CreateResulExecutedContext(ActionExecutingContext actionContext)
+ {
+ return new ResultExecutedContext(
+ actionContext,
+ actionContext.Filters,
+ new OkObjectResult(new KeyValuePair<string, string>("Greeting", "Hello world")),
+ actionContext.Controller);
+ }
+ }
+}
\ No newline at end of file

IdempotentAttribute for WabiSabi coordinator actions