Add `IdempotencyRequestCache`.
ADD
WalletWasabi.Backend/Controllers/WabiSabi/IdempotencyRequestCache.cs
+using Microsoft.Extensions.Caching.Memory;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.WabiSabi.Backend.Models;
+using WalletWasabi.WabiSabi.Crypto;
+
+namespace WalletWasabi.Backend.Controllers.WabiSabi
+{
+ public class IdempotencyRequestCache
+ {
+ public IdempotencyRequestCache(IMemoryCache cache)
+ {
+ ResponseCache = cache;
+ }
+
+ public delegate Task<TResponse> ProcessRequestDelegateAsync<TRequest, TResponse>(TRequest sender, CancellationToken cancellationToken);
+
+ /// <summary>Timeout specifying how long a request response can stay in memory.</summary>
+ private static TimeSpan CacheTimeout { get; } = TimeSpan.FromMinutes(5);
+
+ private static TimeSpan RequestTimeout { get; } = TimeSpan.FromMinutes(1);
+
+ /// <summary>Guards <see cref="ResponseCache"/>.</summary>
+ /// <remarks>Unfortunately, <see cref="CacheExtensions.GetOrCreate{TItem}(IMemoryCache, object, Func{ICacheEntry, TItem})"/> is not atomic.</remarks>
+ /// <seealso href="https://github.com/dotnet/runtime/issues/36499"/>
+ private object ResponseCacheLock { get; } = new();
+
+ /// <remarks>Guarded by <see cref="ResponseCacheLock"/>.</remarks>
+ private IMemoryCache ResponseCache { get; }
+
+ /// <typeparam name="TRequest">
+ /// <see langword="record"/>s are preferred as <see cref="object.GetHashCode"/>
+ /// and <see cref="object.Equals(object?)"/> are generated for <see langword="record"/> types automatically.
+ /// </typeparam>
+ /// <typeparam name="TResponse">Type associated with <typeparamref name="TRequest"/>. The correspondence should be 1:1 mapping.</typeparam>
+ public async Task<TResponse> GetCachedResponseAsync<TRequest, TResponse>(TRequest request, ProcessRequestDelegateAsync<TRequest, TResponse> action, CancellationToken cancellationToken = default) where TRequest : notnull where TResponse : class
+ {
+ bool callAction = false;
+ TaskCompletionSource<TResponse> responseTcs;
+
+ while (true)
+ {
+ lock (ResponseCacheLock)
+ {
+ if (!ResponseCache.TryGetValue(request, out responseTcs))
+ {
+ callAction = true;
+ responseTcs = new();
+ ResponseCache.Set(request, responseTcs, DateTimeOffset.UtcNow.Add(CacheTimeout));
+ }
+ }
+
+ TResponse result;
+
+ if (callAction)
+ {
+ try
+ {
+ result = await action(request, cancellationToken).ConfigureAwait(false);
+ responseTcs.SetResult(result);
+ return result;
+ }
+ catch (Exception e)
+ {
+ responseTcs.SetException(e);
+ throw;
+ }
+ }
+ else
+ {
+ using CancellationTokenSource timeoutCts = new(RequestTimeout);
+ using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(timeoutCts.Token, cancellationToken);
+
+ try
+ {
+ // All requests where this idempotent behavior (i.e. retry behavior) makes sense are mutex/CPU bound on the server,
+ // and perform no IO or network requests, so the only reason that processing of a request should ever time out is if we have a deadlock type bug.
+ result = await responseTcs.Task.WithAwaitCancellationAsync(linkedCts.Token).ConfigureAwait(false);
+ return result;
+ }
+ catch (OperationCanceledException e)
+ {
+ if (e.CancellationToken == cancellationToken)
+ {
+ // Cancelled by application shutting down or when the HTTP request is cancelled.
+ throw;
+ }
+ }
+ catch (Exception e) when (e is WabiSabiProtocolException or WabiSabiCryptoException)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ lock (ResponseCacheLock)
+ {
+ ResponseCache.Remove(request);
+ }
+ }
+ }
+ }
+ }
+ }
+}

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.Controllers.WabiSabi;
using WalletWasabi.Backend.Filters;
using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
@@ -14,11 +15,13 @@ namespace WalletWasabi.Backend.Controllers
[Produces("application/json")]
public class WabiSabiController : ControllerBase, IWabiSabiApiRequestHandler
{
- public WabiSabiController(Arena arena)
+ public WabiSabiController(IdempotencyRequestCache idempotencyRequestCache, Arena arena)
{
+ IdempotencyRequestCache = idempotencyRequestCache;
Arena = arena;
}
+ private IdempotencyRequestCache IdempotencyRequestCache { get; }
private Arena Arena { get; }
[HttpGet("status")]
@@ -28,31 +31,27 @@ namespace WalletWasabi.Backend.Controllers
}
[HttpPost("connection-confirmation")]
- public Task<ConnectionConfirmationResponse> ConfirmConnectionAsync(ConnectionConfirmationRequest request, CancellationToken cancellableToken)
+ public Task<ConnectionConfirmationResponse> ConfirmConnectionAsync(ConnectionConfirmationRequest request, CancellationToken cancellationToken)
{
- // TODO: Add idempotent.
- return Arena.ConfirmConnectionAsync(request, cancellableToken);
+ return IdempotencyRequestCache.GetCachedResponseAsync(request, action: (request, token) => Arena.ConfirmConnectionAsync(request, token), cancellationToken);
}
[HttpPost("input-registration")]
- public Task<InputRegistrationResponse> RegisterInputAsync(InputRegistrationRequest request, CancellationToken cancellableToken)
+ public Task<InputRegistrationResponse> RegisterInputAsync(InputRegistrationRequest request, CancellationToken cancellationToken)
{
- // TODO: Add idempotent.
- return Arena.RegisterInputAsync(request, cancellableToken);
+ return IdempotencyRequestCache.GetCachedResponseAsync(request, action: (request, token) => Arena.RegisterInputAsync(request, token), cancellationToken);
}
[HttpPost("output-registration")]
- public Task<EmptyResponse> RegisterOutputAsync(OutputRegistrationRequest request, CancellationToken cancellableToken)
+ public Task<EmptyResponse> RegisterOutputAsync(OutputRegistrationRequest request, CancellationToken cancellationToken)
{
- // TODO: Add idempotent.
- return Arena.RegisterOutputAsync(request, cancellableToken);
+ return IdempotencyRequestCache.GetCachedResponseAsync(request, action: (request, token) => Arena.RegisterOutputAsync(request, token), cancellationToken);
}
[HttpPost("credential-issuance")]
- public Task<ReissueCredentialResponse> ReissuanceAsync(ReissueCredentialRequest request, CancellationToken cancellableToken)
+ public Task<ReissueCredentialResponse> ReissuanceAsync(ReissueCredentialRequest request, CancellationToken cancellationToken)
{
- // TODO: Add idempotent.
- return Arena.ReissuanceAsync(request, cancellableToken);
+ return IdempotencyRequestCache.GetCachedResponseAsync(request, action: (request, token) => Arena.ReissuanceAsync(request, token), cancellationToken);
}
[HttpPost("input-unregistration")]

MODIFY
WalletWasabi.Backend/Startup.cs
WalletWasabi.Backend/Startup.cs
@@ -13,13 +13,12 @@ using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
+using WalletWasabi.Backend.Controllers.WabiSabi;
using WalletWasabi.Backend.Middlewares;
using WalletWasabi.Helpers;
using WalletWasabi.Interfaces;
using WalletWasabi.Logging;
using WalletWasabi.WabiSabi;
-using WalletWasabi.WabiSabi.Backend.PostRequests;
-using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Models.Serialization;
using WalletWasabi.WebClients;
@@ -72,6 +71,7 @@ namespace WalletWasabi.Backend
services.AddLogging(logging => logging.AddFilter((s, level) => level >= Microsoft.Extensions.Logging.LogLevel.Warning));
services.AddSingleton<IExchangeRateProvider>(new ExchangeRateProvider());
+ services.AddSingleton<IdempotencyRequestCache>();
services.AddSingleton(new Global(Configuration["datadir"]));
services.AddSingleton(serviceProvider =>
{

ADD
WalletWasabi.Tests/UnitTests/WabiSabi/Backend/Controllers/IdempotencyRequestCacheTests.cs
+using Microsoft.Extensions.Caching.Memory;
+using NBitcoin;
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using WalletWasabi.Backend.Controllers.WabiSabi;
+using WalletWasabi.WabiSabi.Backend.Models;
+using Xunit;
+
+namespace WalletWasabi.Tests.UnitTests.WabiSabi.Backend.Controllers
+{
+ /// <summary>
+ /// Tests for <see cref="IdempotencyRequestCache"/> class.
+ /// </summary>
+ public class IdempotencyRequestCacheTests
+ {
+ /// <summary>
+ /// Very basic test that a correct response is returned.
+ /// </summary>
+ [Fact]
+ public async Task BasicCacheBehaviorAsync()
+ {
+ using MemoryCache memoryCache = new(new MemoryCacheOptions());
+ IdempotencyRequestCache cache = new(memoryCache);
+
+ SimpleRequestType request = new(); // A request that is presented for the first time.
+ SimpleResponseType preparedResponse = new(); // Pre-prepared response for testing purposes.
+
+ SimpleResponseType response = await cache.GetCachedResponseAsync(request, action: (request, cancellationToken) => Task.FromResult(preparedResponse));
+
+ // Verify that the response is really what we pre-prepared
+ Assert.NotNull(response);
+ Assert.IsType<SimpleResponseType>(response);
+ Assert.Same(preparedResponse, response); // Compare by reference.
+ }
+
+ /// <summary>
+ /// Simulates two simultaneous requests and both have the same chance of succeeding.
+ /// One of the requests should be served from cache.
+ /// </summary>
+ [Fact]
+ public async Task TwoSimultaneousCacheRequestsAsync()
+ {
+ using MemoryCache memoryCache = new(new MemoryCacheOptions());
+ IdempotencyRequestCache cache = new(memoryCache);
+
+ SimpleRequestType request = new(); // A request that is presented for the first time.
+ SimpleResponseType preparedResponse = new(); // Pre-prepared response for testing purposes.
+
+ // How many times the GetCachedResponseAsync action was called.
+ long counter = 0;
+
+ Task<SimpleResponseType> task1 = cache.GetCachedResponseAsync(request, action: GetResponseAsync);
+ Task<SimpleResponseType> task2 = cache.GetCachedResponseAsync(request, action: GetResponseAsync);
+
+ SimpleResponseType[] responses = await Task.WhenAll(task1, task2);
+
+ // Verify that GetCachedResponseAsync action delegate is called only once.
+ Assert.Equal(1, Interlocked.Read(ref counter));
+
+ // Two responses must come.
+ Assert.Equal(2, responses.Length);
+
+ // ... and they must represent the same object (compared by reference).
+ Assert.Same(responses[0], responses[1]);
+
+ Task<SimpleResponseType> GetResponseAsync(SimpleRequestType request, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref counter);
+ return Task.FromResult(preparedResponse);
+ }
+ }
+
+ /// <summary>
+ /// If the first request fails with <see cref="WabiSabiProtocolException"/>,
+ /// then the second request will try again.
+ /// </summary>
+ [Fact]
+ public async Task FailureIsNotCachedAsync()
+ {
+ using MemoryCache memoryCache = new(new MemoryCacheOptions());
+ IdempotencyRequestCache cache = new(memoryCache);
+
+ SimpleRequestType request = new(); // A request that is presented for the first time.
+ SimpleResponseType preparedResponse = new(); // Pre-prepared response for testing purposes.
+
+ // First request.
+ InvalidOperationException ex1 = await Assert.ThrowsAsync<InvalidOperationException>(() => cache.GetCachedResponseAsync(request, action: ExceptionAsync));
+
+ // Second request.
+ SimpleResponseType response = await cache.GetCachedResponseAsync(request, action: SuccessAsync);
+ Assert.Same(preparedResponse, response);
+
+ Task<SimpleResponseType> ExceptionAsync(SimpleRequestType request, CancellationToken cancellationToken)
+ {
+ throw new InvalidOperationException();
+ }
+
+ Task<SimpleResponseType> SuccessAsync(SimpleRequestType request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(preparedResponse);
+ }
+ }
+
+ /// <summary>
+ /// Cache key comparisons are done by GethHashCode and Equals implementation of "request" types.
+ /// <see cref="HashCodeRequestType"/> has its own <see cref="object.GetHashCode"/> and <see cref="object.Equals(object?)"/> implementation.
+ /// </summary>
+ [Fact]
+ public async Task RequestsAreCachedByHashCode1Async()
+ {
+ using MemoryCache memoryCache = new(new MemoryCacheOptions());
+ IdempotencyRequestCache cache = new(memoryCache);
+
+ HashCodeRequestType request1 = new(hashCode: 7, importantValue: 42);
+ HashCodeRequestType request2 = new(hashCode: 7, importantValue: 24);
+ HashCodeRequestType request3 = new(hashCode: 14, importantValue: 24);
+
+ HashCodeResponseType response1 = await cache.GetCachedResponseAsync(request1, action: ReturnNewAsync);
+ HashCodeResponseType response2 = await cache.GetCachedResponseAsync(request2, action: ReturnNewAsync);
+ HashCodeResponseType response3 = await cache.GetCachedResponseAsync(request3, action: ReturnNewAsync);
+
+ // The same hash code of two objects lead to the same response even some object values are different.
+ Assert.Same(response1, response2);
+
+ // request3 has different hash code (14) than other requests (7).
+ Assert.NotSame(response1, response3);
+ Assert.NotSame(response2, response3);
+
+ static Task<HashCodeResponseType> ReturnNewAsync(HashCodeRequestType request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new HashCodeResponseType());
+ }
+ }
+
+ /// <summary>
+ /// Cache key comparisons are done by GethHashCode and Equals implementation of "request" types.
+ /// </summary>
+ [Fact]
+ public async Task RequestsAreCachedByHashCode2Async()
+ {
+ using MemoryCache memoryCache = new(new MemoryCacheOptions());
+ IdempotencyRequestCache cache = new(memoryCache);
+
+ ComplexRequestType request1 = new(ImportantValue: 7, ParentHash: uint256.One);
+ ComplexRequestType request2 = new(ImportantValue: 7, ParentHash: uint256.One);
+ ComplexRequestType request3 = new(ImportantValue: 7, ParentHash: uint256.Zero);
+
+ ComplexResponseType response1 = await cache.GetCachedResponseAsync(request1, action: ReturnNewAsync);
+ ComplexResponseType response2 = await cache.GetCachedResponseAsync(request2, action: ReturnNewAsync);
+ ComplexResponseType response3 = await cache.GetCachedResponseAsync(request3, action: ReturnNewAsync);
+
+ // The same hash code of two objects lead to the same response even some object values are different.
+ Assert.Same(response1, response2);
+
+ // request3 has different hash code (14) than other requests (7).
+ Assert.NotSame(response1, response3);
+ Assert.NotSame(response2, response3);
+
+ static Task<ComplexResponseType> ReturnNewAsync(ComplexRequestType request, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(new ComplexResponseType());
+ }
+ }
+
+ private record SimpleRequestType();
+ private record SimpleResponseType();
+
+ private record ComplexRequestType(int ImportantValue, uint256 ParentHash);
+ private record ComplexResponseType();
+
+ /// <summary>Type that allows us to specify arbitrary object hash code.</summary>
+ private class HashCodeRequestType : IEquatable<HashCodeRequestType>
+ {
+ public HashCodeRequestType(int hashCode, int importantValue)
+ {
+ HashCode = hashCode;
+ ImportantValue = importantValue;
+ }
+
+ public int HashCode { get; }
+ public int ImportantValue { get; }
+
+ public override bool Equals(object? obj) => Equals(obj as HashCodeRequestType);
+
+ public bool Equals(HashCodeRequestType? other)
+ {
+ return other is not null && HashCode == other.HashCode;
+ }
+
+ public override int GetHashCode()
+ {
+ return HashCode;
+ }
+ }
+
+ private class HashCodeResponseType
+ {
+ }
+ }
+}

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Client/ArenaClientTests.cs
+using Microsoft.Extensions.Caching.Memory;
using Moq;
using NBitcoin;
using NBitcoin.RPC;
@@ -6,6 +7,7 @@ using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using WalletWasabi.Backend.Controllers;
+using WalletWasabi.Backend.Controllers.WabiSabi;
using WalletWasabi.BitcoinCore.Rpc;
using WalletWasabi.Crypto;
using WalletWasabi.Crypto.Randomness;
@@ -15,7 +17,6 @@ using WalletWasabi.Tests.Helpers;
using WalletWasabi.WabiSabi;
using WalletWasabi.WabiSabi.Backend;
using WalletWasabi.WabiSabi.Backend.Models;
-using WalletWasabi.WabiSabi.Backend.PostRequests;
using WalletWasabi.WabiSabi.Backend.Rounds;
using WalletWasabi.WabiSabi.Client;
using WalletWasabi.WabiSabi.Crypto;
@@ -60,7 +61,10 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(config, mockRpc, round);
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromMinutes(1));
- var wabiSabiApi = new WabiSabiController(arena);
+ using var memoryCache = new MemoryCache(new MemoryCacheOptions());
+ var idempotencyRequestCache = new IdempotencyRequestCache(memoryCache);
+ var wabiSabiApi = new WabiSabiController(idempotencyRequestCache, arena);
+
var insecureRandom = new InsecureRandom();
var roundState = RoundState.FromRound(round);
var aliceArenaClient = new ArenaClient(
@@ -175,7 +179,10 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
using Arena arena = await ArenaBuilder.From(config).CreateAndStartAsync(round);
- var wabiSabiApi = new WabiSabiController(arena);
+ using var memoryCache = new MemoryCache(new MemoryCacheOptions());
+ var idempotencyRequestCache = new IdempotencyRequestCache(memoryCache);
+ var wabiSabiApi = new WabiSabiController(idempotencyRequestCache, arena);
+
var apiClient = new ArenaClient(null!, null!, wabiSabiApi);
round.SetPhase(Phase.InputRegistration);
@@ -201,7 +208,9 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(config, round);
var mockRpc = new Mock<IRPCClient>();
- var wabiSabiApi = new WabiSabiController(arena);
+ using var memoryCache = new MemoryCache(new MemoryCacheOptions());
+ var idempotencyRequestCache = new IdempotencyRequestCache(memoryCache);
+ var wabiSabiApi = new WabiSabiController(idempotencyRequestCache, arena);
var rnd = new InsecureRandom();
var amountClient = new WabiSabiClient(round.AmountCredentialIssuerParameters, rnd, 4300000000000L);

MODIFY
WalletWasabi.Tests/UnitTests/WabiSabi/Client/BobClientTests.cs
WalletWasabi.Tests/UnitTests/WabiSabi/Client/BobClientTests.cs
+using Microsoft.Extensions.Caching.Memory;
using NBitcoin;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using WalletWasabi.Backend.Controllers;
+using WalletWasabi.Backend.Controllers.WabiSabi;
using WalletWasabi.Blockchain.TransactionOutputs;
using WalletWasabi.Crypto.Randomness;
using WalletWasabi.Tests.Helpers;
@@ -32,8 +34,11 @@ namespace WalletWasabi.Tests.UnitTests.WabiSabi.Client
using Arena arena = await WabiSabiFactory.CreateAndStartArenaAsync(config, mockRpc, round);
await arena.TriggerAndWaitRoundAsync(TimeSpan.FromMinutes(1));
+ using var memoryCache = new MemoryCache(new MemoryCacheOptions());
+ var idempotencyRequestCache = new IdempotencyRequestCache(memoryCache);
+ var wabiSabiApi = new WabiSabiController(idempotencyRequestCache, arena);
+
var insecureRandom = new InsecureRandom();
- var wabiSabiApi = new WabiSabiController(arena);
var roundState = RoundState.FromRound(round);
var aliceArenaClient = new ArenaClient(
roundState.CreateAmountCredentialClient(insecureRandom),

MODIFY
WalletWasabi/WabiSabi/Models/EmptyResponse.cs
WalletWasabi/WabiSabi/Models/EmptyResponse.cs
+using System.Threading.Tasks;
+
namespace WalletWasabi.WabiSabi.Models
{
+ /// <remarks>Type useful to avoid implementing API methods for both <see cref="Task"/> and <see cref="Task{TResult}"/> return types.</remarks>
public record EmptyResponse()
{
public static readonly EmptyResponse Instance = new();

Add `IdempotencyRequestCache`.