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
1 change: 0 additions & 1 deletion csharp/src/StationAPI/Configuration/StationConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,6 @@ public class SolverConfig
public string? LogoUrl { get; set; }
public string GrpcEndpoint { get; set; } = null!;
public string? WebhookSecret { get; set; }
public string? AdminApiUrl { get; set; }

/// <summary>
/// Bearer token for the solver's Gateway gRPC API. Format: <c>tsk_test_*</c> or
Expand Down
4 changes: 0 additions & 4 deletions csharp/src/StationAPI/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,6 @@
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IFaucetRateLimiter, MemoryCacheFaucetRateLimiter>();
builder.Services.AddTransient<IFaucetService, FaucetService>();
builder.Services.AddHttpClient("solver-admin", client =>
{
client.Timeout = TimeSpan.FromSeconds(15);
});

// Binance price feed HttpClient
builder.Services.AddHttpClient("binance", client =>
Expand Down
68 changes: 33 additions & 35 deletions csharp/src/StationAPI/Services/FaucetService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
using System.Net.Http.Json;
using System.Numerics;
using System.Text.Json;
using Grpc.Core;
using Train.Solver.Proto;
using Train.Solver.StationAPI.Configuration;
using Train.Solver.StationAPI.Helpers;

Expand All @@ -11,16 +11,16 @@ public class FaucetService : IFaucetService
private const string EvmNativeAddress = "0x0000000000000000000000000000000000000000";

private readonly IStationConfigProvider _configProvider;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ISolverClientFactory _clientFactory;
private readonly ILogger<FaucetService> _logger;

public FaucetService(
IStationConfigProvider configProvider,
IHttpClientFactory httpClientFactory,
ISolverClientFactory clientFactory,
ILogger<FaucetService> logger)
{
_configProvider = configProvider;
_httpClientFactory = httpClientFactory;
_clientFactory = clientFactory;
_logger = logger;
}

Expand All @@ -36,10 +36,10 @@ public FaucetService(

public async Task<FaucetClaimResult> ClaimAsync(FaucetItemConfig item, string recipientAddress, CancellationToken ct)
{
var solver = _configProvider.Current.Solvers.FirstOrDefault(s => !string.IsNullOrEmpty(s.AdminApiUrl));
if (solver is null || string.IsNullOrEmpty(solver.AdminApiUrl))
var solver = _configProvider.Current.Solvers.FirstOrDefault();
if (solver is null)
{
return new FaucetClaimResult(false, null, "No solver with admin API configured");
return new FaucetClaimResult(false, null, "No solver configured");
}

if (!BigInteger.TryParse(item.Amount, out var amount) || amount.Sign <= 0)
Expand All @@ -57,46 +57,44 @@ public async Task<FaucetClaimResult> ClaimAsync(FaucetItemConfig item, string re
return new FaucetClaimResult(false, null, ex.Message);
}

var client = _clientFactory.GetExecutionClient(solver.Id);
if (client is null)
{
return new FaucetClaimResult(false, null, "Solver execution client unavailable");
}

var correlationId = $"faucet-{Guid.NewGuid()}";
var networkIdentifier = item.SolverNetworkIdentifier ?? item.Caip2Id;

var payload = new
var request = new SubmitExecutionRequest
{
networkSlug = networkIdentifier,
walletAddress = item.MinterAddress,
toAddress = item.TokenContract,
callData,
amount = "0",
tokenContract = EvmNativeAddress,
correlationId
NetworkSlug = networkIdentifier,
WalletAddress = item.MinterAddress,
ToAddress = item.TokenContract,
CallData = callData,
Amount = "0",
TokenContract = EvmNativeAddress,
CorrelationId = correlationId,
};

var http = _httpClientFactory.CreateClient("solver-admin");
var url = solver.AdminApiUrl.TrimEnd('/') + "/api/custom-transaction";

try
{
using var response = await http.PostAsJsonAsync(url, payload, ct);
if (!response.IsSuccessStatusCode)
{
var body = await response.Content.ReadAsStringAsync(ct);
_logger.LogWarning("Faucet claim rejected by solver: {Status} {Body}", (int)response.StatusCode, body);
return new FaucetClaimResult(false, null, $"Solver responded {(int)response.StatusCode}");
}

using var stream = await response.Content.ReadAsStreamAsync(ct);
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: ct);
if (doc.RootElement.TryGetProperty("accepted", out var accepted) && accepted.ValueKind == JsonValueKind.False)
var response = await client.SubmitTransactionAsync(request, cancellationToken: ct);
if (!response.Accepted)
{
return new FaucetClaimResult(false, null, "Solver did not accept the transaction");
var error = string.IsNullOrEmpty(response.ErrorMessage)
? "Solver did not accept the transaction"
: response.ErrorMessage;
_logger.LogWarning("Faucet claim rejected by solver: {Error}", error);
return new FaucetClaimResult(false, null, error);
}

return new FaucetClaimResult(true, correlationId, null);
return new FaucetClaimResult(true, response.CorrelationId, null);
}
catch (HttpRequestException ex)
catch (RpcException ex)
{
_logger.LogError(ex, "Failed to call solver admin API for faucet claim");
return new FaucetClaimResult(false, null, "Failed to reach solver");
_logger.LogError(ex, "Faucet claim gRPC call failed");
return new FaucetClaimResult(false, null, ex.Status.Detail ?? "Failed to reach solver");
}
}
}
18 changes: 15 additions & 3 deletions csharp/src/StationAPI/Services/SolverClientFactory.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Grpc.Net.Client;
using Grpc.Net.Client.Web;
Expand All @@ -10,6 +11,7 @@ namespace Train.Solver.StationAPI.Services;
public interface ISolverClientFactory
{
SolverService.SolverServiceClient? GetClient(string solverId);
ExecutionService.ExecutionServiceClient? GetExecutionClient(string solverId);
}

public class SolverClientFactory : ISolverClientFactory, IDisposable
Expand All @@ -23,6 +25,18 @@ public SolverClientFactory(IStationConfigProvider configProvider)
}

public SolverService.SolverServiceClient? GetClient(string solverId)
{
var invoker = GetInvoker(solverId);
return invoker is null ? null : new SolverService.SolverServiceClient(invoker);
}

public ExecutionService.ExecutionServiceClient? GetExecutionClient(string solverId)
{
var invoker = GetInvoker(solverId);
return invoker is null ? null : new ExecutionService.ExecutionServiceClient(invoker);
}

private CallInvoker? GetInvoker(string solverId)
{
var solver = _configProvider.Current.Solvers.FirstOrDefault(s => s.Id == solverId);
if (solver is null) return null;
Expand All @@ -35,11 +49,9 @@ public SolverClientFactory(IStationConfigProvider configProvider)

// Channels are cached per endpoint, but the API key is per solver — wrap the
// invoker so each client carries its own auth header. CallInvoker is cheap.
var invoker = string.IsNullOrEmpty(solver.ApiKey)
return string.IsNullOrEmpty(solver.ApiKey)
? channel.CreateCallInvoker()
: channel.Intercept(new ApiKeyClientInterceptor(solver.ApiKey));

return new SolverService.SolverServiceClient(invoker);
}

public void Dispose()
Expand Down
3 changes: 1 addition & 2 deletions csharp/src/StationAPI/station-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,7 @@
"logoUrl": "https://raw.githubusercontent.com/TrainProtocol/.github/main/assets/logo.png",
"grpcEndpoint": "https://train-solver-api.lb.layerswap.io",
"webhookSecret": "station-webhook-secret-1",
"adminApiUrl": "https://train-solver-admin.lb.layerswap.io",
"apiKey": null
"apiKey": "tsk_test_jw9ku1zA2ETneTV-_Rm7BveBLOR_8fGXNkKR6bxbUBM"
}
],
"faucet": {
Expand Down
Loading